Создан single-file index.html — вся логика в одном файле для работы через file://

This commit is contained in:
2026-07-30 17:03:49 +03:00
parent 971efcc2d9
commit 72d91667e4
+272
View File
@@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка выручки водителей</title>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.19.2/dist/xlsx.full.min.js"></script>
<style>
/* ===== Переменные и тема ===== */
:root{--primary:#6366f1;--primary-dark:#4f46e5;--primary-light:#a5b4fc;--bg-start:#4f46e5;--bg-end:#7c3aed;--surface:#fff;--surface-alt:#f8fafc;--text:#1e293b;--text-secondary:#64748b;--text-muted:#94a3b8;--border:#e2e8f0;--success:#10b981;--success-bg:#d1fae5;--success-text:#065f46;--warning:#f59e0b;--warning-bg:#fef3c7;--warning-text:#92400e;--danger:#ef4444;--danger-bg:#fee2e2;--danger-text:#991b1b;--radius:16px;--radius-sm:8px;--radius-xs:6px;--shadow-md:0 4px 20px rgba(0,0,0,.1);--shadow-lg:0 10px 40px rgba(0,0,0,.12);--transition:.25s cubic-bezier(.4,0,.2,1)}
[data-theme=dark]{--bg-start:#111827;--bg-end:#1e293b;--surface:#1f2937;--surface-alt:#111827;--text:#f9fafb;--text-secondary:#d1d5db;--text-muted:#9ca3af;--border:#374151;--success-bg:#064e3b;--success-text:#6ee7b7;--warning-bg:#78350f;--warning-text:#fcd34d;--danger-bg:#7f1d1d;--danger-text:#fca5a5}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:linear-gradient(160deg,var(--bg-start) 0%,var(--bg-end) 50%,#a855f7 100%);min-height:100vh;color:var(--text);line-height:1.6}
.container{max-width:1100px;margin:0 auto;padding:2rem 1.5rem 4rem;position:relative;z-index:1}
.hero{text-align:center;padding:2rem 1rem 3rem;color:#fff}
.hero-badge{display:inline-flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.15);backdrop-filter:blur(10px);padding:.4rem 1rem;border-radius:20px;font-size:.85rem;font-weight:500;margin-bottom:1.25rem;border:1px solid rgba(255,255,255,.2)}
.hero h1{font-size:clamp(1.8rem,4vw,2.75rem);font-weight:800;letter-spacing:-.02em;margin-bottom:.75rem;text-shadow:0 2px 10px rgba(0,0,0,.15)}
.hero-subtitle{font-size:1.05rem;opacity:.9;max-width:650px;margin:0 auto;line-height:1.7}
/* ===== Статус-бар ===== */
.status-bar{background:var(--surface);border-radius:var(--radius-sm);padding:.75rem 1.25rem;margin-bottom:1rem;font-size:.9rem;font-weight:500;box-shadow:var(--shadow-md);animation:slideIn .3s ease;border-left:4px solid var(--primary)}
.status-info{border-left-color:var(--primary);color:var(--text)}
.status-success{border-left-color:var(--success);color:var(--success-text);background:var(--success-bg)}
.status-warning{border-left-color:var(--warning);color:var(--warning-text);background:var(--warning-bg)}
.status-error{border-left-color:var(--danger);color:var(--danger-text);background:var(--danger-bg)}
/* ===== Карточки ===== */
.card{background:var(--surface);border-radius:var(--radius);box-shadow:var(--shadow-lg);margin-bottom:1.5rem;overflow:hidden;transition:box-shadow var(--transition)}
.card:hover{box-shadow:0 15px 50px rgba(0,0,0,.15)}
.card-head{display:flex;align-items:center;gap:1rem;padding:1.25rem 2rem;border-bottom:1px solid var(--border);background:var(--surface-alt)}
.step-badge{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--primary),var(--bg-end));color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:1rem;flex-shrink:0}
.card-head h2{font-size:1.25rem;font-weight:700}
.card-body{padding:2rem}
/* ===== Dropzone ===== */
.dropzone{border:2px dashed var(--border);border-radius:12px;padding:3rem 2rem;text-align:center;cursor:pointer;transition:all var(--transition);background:var(--surface-alt)}
.dropzone:hover,.dropzone.dragover{border-color:var(--primary);background:#eef2ff;transform:scale(1.01)}
[data-theme=dark] .dropzone:hover,[data-theme=dark] .dropzone.dragover{background:#1e1b4b}
.dropzone.dragover{box-shadow:0 0 0 4px rgba(99,102,241,.15)}
.dropzone-icon{width:72px;height:72px;margin:0 auto 1.25rem;background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-radius:50%;display:flex;align-items:center;justify-content:center;transition:transform var(--transition)}
[data-theme=dark] .dropzone-icon{background:linear-gradient(135deg,#312e81,#4338ca)}
.dropzone:hover .dropzone-icon{transform:scale(1.1)}
.dropzone-title{font-size:1.1rem;font-weight:600;margin-bottom:.5rem}
.dropzone-hint{font-size:.9rem;color:var(--text-secondary);max-width:450px;margin:0 auto}
/* ===== Список файлов ===== */
.file-list{margin-top:1.5rem}
.file-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;background:var(--surface-alt);border-radius:var(--radius-sm);margin-bottom:.5rem;border:1px solid var(--border);animation:slideIn .3s ease}
.file-item.file-error{border-color:var(--danger);background:var(--danger-bg)}
@keyframes slideIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
.file-icon{width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:1.1rem;flex-shrink:0}
.file-icon.vedomost{background:#dbeafe}
[data-theme=dark] .file-icon.vedomost{background:#1e3a5f}
.file-icon.transaction{background:#fef3c7}
[data-theme=dark] .file-icon.transaction{background:#5c3d0e}
.file-info{flex:1;min-width:0}
.file-name{font-size:.9rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-type{font-size:.8rem;color:var(--text-muted)}
.file-remove{width:28px;height:28px;border:none;background:transparent;border-radius:6px;cursor:pointer;color:var(--text-muted);font-size:1.1rem;display:flex;align-items:center;justify-content:center;transition:all var(--transition)}
.file-remove:hover{background:var(--danger-bg);color:var(--danger)}
/* ===== Кнопки ===== */
.actions{display:flex;gap:.75rem;margin-top:1.5rem;flex-wrap:wrap}
.btn{padding:.75rem 1.75rem;border:none;border-radius:10px;font-size:.95rem;font-weight:600;cursor:pointer;transition:all var(--transition);display:inline-flex;align-items:center;gap:.5rem;white-space:nowrap}
.btn:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
.btn-primary{background:linear-gradient(135deg,var(--primary),var(--bg-end));color:#fff;box-shadow:0 4px 15px rgba(99,102,241,.35)}
.btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 6px 25px rgba(99,102,241,.45)}
.btn-outline{background:transparent;color:var(--text-secondary);border:1.5px solid var(--border)}
.btn-outline:hover:not(:disabled){background:var(--surface-alt);border-color:var(--text-muted)}
.btn-success{background:linear-gradient(135deg,#10b981,#059669);color:#fff;box-shadow:0 4px 15px rgba(16,185,129,.3)}
.btn-success:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 6px 20px rgba(16,185,129,.4)}
.btn-export{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
.btn-export:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 6px 20px rgba(245,158,11,.4)}
.btn-theme{background:linear-gradient(135deg,#94a3b8,#64748b);color:#fff}
.btn-theme:hover:not(:disabled){transform:translateY(-2px)}
/* ===== Прогресс-бар ===== */
.progress-bar{width:100%;height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:1rem;display:none}
.progress-bar.active{display:block}
.progress-fill{height:100%;background:linear-gradient(90deg,var(--primary),var(--bg-end));border-radius:2px;width:0%}
/* ===== Таблицы ===== */
.table-wrap{overflow-x:auto;border-radius:var(--radius-sm);border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:.875rem}
thead{background:var(--surface-alt)}
th,td{padding:.75rem 1rem;border-bottom:1px solid var(--border);text-align:left}
th{font-weight:600;color:var(--text-secondary);white-space:nowrap;user-select:none}
th[data-key]::after{content:' ↕';opacity:.3;font-size:.75rem}
th.sort-asc::after{content:' ↑';opacity:1;color:var(--primary)}
th.sort-desc::after{content:' ↓';opacity:1;color:var(--primary)}
tbody tr:hover{background:var(--surface-alt)}
tbody tr:last-child td{border-bottom:none}
/* ===== Бейджи ===== */
.badge{display:inline-flex;align-items:center;gap:.3rem;padding:.2rem .65rem;border-radius:6px;font-size:.8rem;font-weight:600}
.badge-ok{background:var(--success-bg);color:var(--success-text)}
.badge-warn{background:var(--warning-bg);color:var(--warning-text)}
.badge-err{background:var(--danger-bg);color:var(--danger-text)}
/* ===== Вкладки ===== */
.tabs-wrapper{border-bottom:2px solid var(--border);margin-bottom:1.5rem;display:flex;gap:.25rem;overflow-x:auto;scrollbar-width:none}
.tabs-wrapper::-webkit-scrollbar{display:none}
.tab-btn{padding:.75rem 1.25rem;background:none;border:none;border-bottom:2.5px solid transparent;cursor:pointer;font-size:.9rem;font-weight:600;color:var(--text-muted);white-space:nowrap;transition:all var(--transition);margin-bottom:-2px}
.tab-btn:hover{color:var(--primary)}
.tab-btn.active{color:var(--primary);border-bottom-color:var(--primary)}
.tab-btn .count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;background:var(--primary-light);color:var(--primary-dark);border-radius:10px;font-size:.75rem;padding:0 6px;margin-left:.35rem}
.tab-panel{display:none;animation:fadeUp .3s ease}
.tab-panel.active{display:block}
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
/* ===== Пустое состояние ===== */
.empty-state{text-align:center;padding:3rem 1rem}
.empty-state-icon{font-size:4rem;margin-bottom:1rem;opacity:.3}
.empty-state p{color:var(--text-muted)}
/* ===== Инфо-карточка ===== */
.info-card{background:var(--surface);border-radius:var(--radius);box-shadow:var(--shadow-md);overflow:hidden}
.info-head{padding:1.25rem 2rem;border-bottom:1px solid var(--border);background:var(--surface-alt);display:flex;align-items:center;gap:.75rem}
.info-head h2{font-size:1.25rem;font-weight:700}
.info-grid{padding:1.5rem 2rem;display:grid;gap:1.25rem}
.info-block{display:flex;gap:1rem;padding:1.25rem;background:var(--surface-alt);border-radius:12px;border:1px solid var(--border);transition:border-color var(--transition)}
.info-block:hover{border-color:var(--primary-light)}
.info-block-icon{width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:1.25rem;flex-shrink:0}
.info-block-icon.blue{background:#dbeafe}
[data-theme=dark] .info-block-icon.blue{background:#1e3a5f}
.info-block-icon.green{background:#d1fae5}
[data-theme=dark] .info-block-icon.green{background:#064e3b}
.info-block-icon.amber{background:#fef3c7}
[data-theme=dark] .info-block-icon.amber{background:#78350f}
.info-block-icon.purple{background:#ede9fe}
[data-theme=dark] .info-block-icon.purple{background:#2e1065}
.info-block-icon.rose{background:#ffe4e6}
[data-theme=dark] .info-block-icon.rose{background:#4c0519}
.info-block-title{font-weight:700;font-size:.95rem;margin-bottom:.35rem}
.info-block-text{font-size:.9rem;color:var(--text-secondary);line-height:1.65}
/* ===== Сводка ===== */
.summary-card{padding:1rem 0}
.summary-card .info-block{border-left:4px solid var(--success)}
.summary-card .info-block.warn{border-left-color:var(--warning)}
.summary-card .info-block.err{border-left-color:var(--danger)}
/* ===== Подвал ===== */
.footer{text-align:center;padding:2rem 1rem;color:rgba(255,255,255,.6);font-size:.85rem}
/* ===== Адаптивность ===== */
@media(max-width:768px){.container{padding:1rem .75rem 3rem}.hero{padding:1.5rem .5rem 2rem}.card-head{padding:1rem 1.25rem}.card-body{padding:1.25rem}.dropzone{padding:2rem 1rem}.actions{flex-direction:column}.btn{width:100%;justify-content:center}.info-grid{padding:1rem}.info-block{flex-direction:column}th,td{padding:.5rem .75rem}}
/* ===== Утилиты ===== */
code{font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:.85em;background:var(--surface-alt);padding:.1em .3em;border-radius:4px;border:1px solid var(--border)}
.diff-positive{color:var(--danger);font-weight:600}
.diff-negative{color:var(--success);font-weight:600}
.diff-zero{color:var(--text-muted)}
</style>
</head>
<body>
<div class="container">
<header class="hero">
<div class="hero-badge">🔒 Работает офлайн — файлы не отправляются на сервер</div>
<h1>Сверка выручки водителей</h1>
<p class="hero-subtitle">Загрузите ведомости приёма наличных и выгрузку транзакций — получите сводную таблицу, расхождения и готовый Excel-отчёт</p>
</header>
<div class="status-bar" id="statusBar" style="display:none"></div>
<section class="card">
<div class="card-head"><div class="step-badge">1</div><h2>Файлы</h2></div>
<div class="card-body">
<div class="dropzone" id="dropzone">
<div class="dropzone-icon"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></div>
<div class="dropzone-title">Перетащите файлы сюда или нажмите для выбора</div>
<div class="dropzone-hint">Любое число ведомостей (<strong>.xlsx</strong>) + выгрузка <strong>TRANSACTIONS_EXT</strong>. Тип определяется автоматически.</div>
</div>
<input type="file" id="fileInput" multiple accept=".xlsx,.xls" style="display:none">
<div class="file-list" id="fileList"></div>
<div class="actions">
<button class="btn btn-primary" id="btnReconcile" disabled>✓ Провести сверку</button>
<button class="btn btn-outline" id="btnClear" disabled>✕ Очистить</button>
<button class="btn btn-export" id="btnExportCsv" disabled>⬇ Экспорт CSV</button>
<button class="btn btn-theme" id="btnToggleTheme">🌙 Тёмная тема</button>
</div>
<div class="progress-bar" id="progressBar"><div class="progress-fill" id="progressFill"></div></div>
</div>
</section>
<section class="card" id="resultCard">
<div class="card-head"><div class="step-badge">2</div><h2>Результат</h2></div>
<div class="card-body">
<div id="emptyState" class="empty-state"><div class="empty-state-icon">📋</div><p>Загрузите файлы и нажмите «Провести сверку»</p></div>
<div id="resultActions" style="display:none;margin-bottom:1.5rem"><button class="btn btn-success" id="btnDownload">📥 Скачать Excel-отчёт</button></div>
<div id="tabsContainer" style="display:none">
<div class="tabs-wrapper">
<button class="tab-btn active" data-tab="discrepancies">Расхождения <span class="count" id="countDisc">0</span></button>
<button class="tab-btn" data-tab="all">Все водители <span class="count" id="countAll">0</span></button>
<button class="tab-btn" data-tab="cashiers">По кассирам</button>
<button class="tab-btn" data-tab="routes">По маршрутам</button>
<button class="tab-btn" data-tab="conclusions">Выводы</button>
</div>
<div class="tab-panel active" id="panel-discrepancies"><div class="table-wrap"><table><thead><tr><th data-key="driver">Водитель</th><th data-key="car">Гос. номер</th><th data-key="given">Сдано (₽)</th><th data-key="collected">Собрано (₽)</th><th data-key="diff">Расхождение</th><th>Статус</th></tr></thead><tbody id="tableDiscrepancies"></tbody></table></div></div>
<div class="tab-panel" id="panel-all"><div class="table-wrap"><table><thead><tr><th data-key="date">Дата</th><th data-key="driver">Водитель</th><th data-key="route">Маршрут</th><th data-key="section">Тип</th><th data-key="given">Сдано (₽)</th><th data-key="collected">Собрано (₽)</th><th data-key="txCount">Транзакций</th></tr></thead><tbody id="tableAll"></tbody></table></div></div>
<div class="tab-panel" id="panel-cashiers"><div class="table-wrap"><table><thead><tr><th data-key="cashier">Кассир</th><th>Ведомостей</th><th data-key="given">Сдано (₽)</th><th data-key="collected">Собрано (₽)</th><th data-key="diff">Расхождение</th></tr></thead><tbody id="tableCashiers"></tbody></table></div></div>
<div class="tab-panel" id="panel-routes"><div class="table-wrap"><table><thead><tr><th data-key="route">Маршрут</th><th>Тип</th><th>Водителей</th><th data-key="sum">Сумма (₽)</th></tr></thead><tbody id="tableRoutes"></tbody></table></div></div>
<div class="tab-panel" id="panel-conclusions"><div id="conclusionsContent"></div></div>
</div>
</div>
</section>
<section class="info-card">
<div class="info-head"><span style="font-size:1.4rem">💡</span><h2>Как это работает</h2></div>
<div class="info-grid">
<div class="info-block"><div class="info-block-icon blue">📄</div><div><div class="info-block-title">Определение типа файла</div><div class="info-block-text">Ведомость распознаётся по заголовку «ВЕДОМОСТЬ ПРИЕМА НАЛИЧНЫХ СРЕДСТВ»; из неё берутся номер документа, дата, ФИО кассира, разделы «Город»/«Пригород» и строки водителей. Выгрузка распознаётся по столбцам CONDUCTOR, DATE и TARIF_SUM.</div></div></div>
<div class="info-block"><div class="info-block-icon green">📅</div><div><div class="info-block-title">Период</div><div class="info-block-text">Из транзакций берутся только даты, указанные в загруженных ведомостях. Остальные даты в сверку не входят.</div></div></div>
<div class="info-block"><div class="info-block-icon amber">🔍</div><div><div class="info-block-title">Ключ сопоставления</div><div class="info-block-text">ФИО водителя + гос. номер ТС. Номера нормализуются (латиница→кириллица), ФИО — верхний регистр, Ё→Е. Опечатки в ФИО сводятся нечётким сравнением при схожести ≥ 88%.</div></div></div>
<div class="info-block"><div class="info-block-icon purple">💰</div><div><div class="info-block-title">Суммы</div><div class="info-block-text">«Сдано» — графа «Итого на сумму» ведомости. «Собрано» — TARIF_SUM ÷ 100 (копейки→рубли) по всем транзакциям водителя за выбранные даты.</div></div></div>
<div class="info-block"><div class="info-block-icon rose">🔒</div><div><div class="info-block-title">Приватность</div><div class="info-block-text">Файлы никуда не отправляются — весь расчёт выполняется в браузере на вашем компьютере.</div></div></div>
</div>
</section>
<div class="footer">Сверка выручки водителей — все вычисления выполняются локально в вашем браузере</div>
</div>
<script>
/* =================================================================
ЕДИНЫЙ СКРИПТ — все модули объединены (single-file версия)
================================================================= */
/* ---------- utils ---------- */
function readExcel(file){return file.arrayBuffer().then(arr=>{const wb=XLSX.read(arr,{type:'array',codepage:1251});return wb.SheetNames.map(name=>({name,data:XLSX.utils.sheet_to_json(wb.Sheets[name],{header:1,defval:''})}))})}
function validateFile(file){const ext=file.name.slice(file.name.lastIndexOf('.')).toLowerCase();if(!['.xlsx','.xls'].includes(ext)){alert('❌ Недопустимый тип: '+ext+'. Разрешены: .xlsx, .xls');return false}return true}
function arrayToCsv(data,headers){return '\uFEFF'+headers.join(',')+'\n'+data.map(r=>headers.map(h=>'"'+String(r[h]??'').replace(/"/g,'""')+'"').join(',')).join('\n')}
function downloadFile(content,filename){const blob=new Blob([content],{type:'application/octet-stream'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);setTimeout(()=>URL.revokeObjectURL(url),1000)}
function downloadExcel(data,filename){const wb=XLSX.utils.book_new();const ws=XLSX.utils.json_to_sheet(data);const colWidths=Object.keys(data[0]||{}).map(key=>{const maxLen=data.reduce((m,r)=>Math.max(m,String(r[key]??'').length),key.length);return{wch:Math.min(maxLen+2,40)}});ws['!cols']=colWidths;XLSX.utils.book_append_sheet(wb,ws,'Сверка');downloadFile(XLSX.write(wb,{bookType:'xlsx',type:'array'}),filename)}
function formatMoney(num){if(num===null||num===undefined||isNaN(num))return'—';return num.toLocaleString('ru-RU',{minimumFractionDigits:0,maximumFractionDigits:0})}
/* ---------- ui ---------- */
function escapeHtml(str){const d=document.createElement('div');d.textContent=str;return d.innerHTML}
function renderFileItem(file,index,type){const isTrans=type==='transaction';const el=document.createElement('div');el.className='file-item';el.dataset.index=index;el.innerHTML='<div class="file-icon '+(isTrans?'transaction':'vedomost')+'">'+(isTrans?'📊':'📋')+'</div><div class="file-info"><div class="file-name">'+escapeHtml(file.name)+'</div><div class="file-type">'+(isTrans?'Выгрузка транзакций':'Ведомость')+' · '+(file.size/1024).toFixed(0)+' КБ</div></div><button class="file-remove" data-i="'+index+'" title="Удалить">✕</button>';return el}
function renderFileError(fileName,message){const el=document.createElement('div');el.className='file-item file-error';el.innerHTML='<div class="file-icon" style="background:#fee2e2;">⚠️</div><div class="file-info"><div class="file-name">'+escapeHtml(fileName)+'</div><div class="file-type" style="color:#ef4444;">'+escapeHtml(message)+'</div></div>';return el}
function sortData(data,key,asc){asc=asc!==false;return[...data].sort((a,b)=>{let av=a[key],bv=b[key];if(av==null)av='';if(bv==null)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')})}
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 st={key:null,asc:true};ths.forEach(th=>{th.style.cursor='pointer';th.addEventListener('click',()=>{const key=th.dataset.key;if(st.key===key)st.asc=!st.asc;else{st.key=key;st.asc=true}ths.forEach(t=>t.classList.remove('sort-asc','sort-desc'));th.classList.add(st.asc?'sort-asc':'sort-desc');renderFn(sortData(sourceData,key,st.asc))})})}
function renderTableBody(tbody,rows,keys,formatter){if(!rows||rows.length===0){tbody.innerHTML='<tr><td colspan="'+(keys||Object.keys(rows[0]||{})).length+'" style="text-align:center;padding:2rem;color:var(--text-muted)">Нет данных</td></tr>';return}tbody.innerHTML=rows.map(row=>{const cells=(keys||Object.keys(row)).map(key=>{const val=row[key];if(formatter)return formatter(val,key,row);if(val==null||val==='')return'—';if(typeof val==='number')return formatMoney(val);return escapeHtml(String(val))}).join('');return'<tr>'+cells+'</tr>'}).join('')}
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 p=document.getElementById('panel-'+btn.dataset.tab);if(p)p.classList.add('active')})})}
let currentTheme=localStorage.getItem('theme')||'light';
function applyTheme(){document.documentElement.setAttribute('data-theme',currentTheme);const btn=document.getElementById('btnToggleTheme');if(btn)btn.textContent=currentTheme==='light'?'🌙 Тёмная тема':'☀️ Светлая тема'}
function initTheme(){applyTheme();document.getElementById('btnToggleTheme')?.addEventListener('click',()=>{currentTheme=currentTheme==='light'?'dark':'light';localStorage.setItem('theme',currentTheme);applyTheme()})}
function showProgress(duration){duration=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=>{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)})}
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'}}
/* ---------- parser ---------- */
const VEDOMOST_HEADER='ВЕДОМОСТЬ ПРИЕМА НАЛИЧНЫХ СРЕДСТВ';
const LATIN_TO_CYRILLIC={A:'А',B:'В',C:'С',E:'Е',H:'Н',K:'К',M:'М',O:'О',P:'Р',T:'Т',X:'Х',Y:'У'};
const FUZZY_THRESHOLD=0.88;
function detectFileType(filename,firstSheet){if(hasHeader(firstSheet,VEDOMOST_HEADER))return'vedomost';const lower=filename.toLowerCase();if(lower.includes('transaction')||lower.includes('transactions')||lower.includes('выгрузка'))return'transaction';if(hasTransactionColumns(firstSheet))return'transaction';if(hasHeader(firstSheet,'ВЕДОМОСТЬ'))return'vedomost';return null}
function hasHeader(sheetData,header){if(!sheetData||sheetData.length===0)return false;for(let i=0;i<Math.min(sheetData.length,20);i++){const row=sheetData[i];if(!row)continue;const joined=row.filter(c=>c!=null).map(String).join(' ').toUpperCase();if(joined.includes(header.toUpperCase()))return true}return false}
function hasTransactionColumns(sheetData){if(!sheetData||sheetData.length===0)return false;for(let i=0;i<Math.min(sheetData.length,5);i++){const row=sheetData[i];if(!row)continue;const cells=row.map(c=>String(c).toUpperCase().trim());if(cells.some(c=>c.includes('CONDUCTOR'))&&cells.some(c=>c.includes('DATE'))&&cells.some(c=>c.includes('TARIF_SUM')||c.includes('TARIF')))return true}return false}
function normalizePlate(plate){if(!plate)return'';let s=String(plate).toUpperCase().trim();s=s.split('').map(ch=>LATIN_TO_CYRILLIC[ch]||ch).join('');s=s.replace(/[^А-ЯЁA-Z0-9]/gi,'');return s}
function normalizeName(name){if(!name)return'';return String(name).toUpperCase().trim().replace(/Ё/g,'Е').replace(/\s+/g,' ')}
function nameSimilarity(a,b){if(!a||!b)return 0;const s1=normalizeName(a),s2=normalizeName(b);if(s1===s2)return 1;if(s1.length===0||s2.length===0)return 0;const dist=levenshtein(s1,s2);return 1-dist/Math.max(s1.length,s2.length)}
function levenshtein(a,b){const m=a.length,n=b.length;const dp=Array.from({length:m+1},()=>Array(n+1).fill(0));for(let i=0;i<=m;i++)dp[i][0]=i;for(let j=0;j<=n;j++)dp[0][j]=j;for(let i=1;i<=m;i++){for(let j=1;j<=n;j++){dp[i][j]=a[i-1]===b[j-1]?dp[i-1][j-1]:1+Math.min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])}}return dp[m][n]}
function parseVedomost(sheetData){if(!sheetData||sheetData.length===0)return null;const result={docNumber:'',date:'',cashier:'',sections:{city:[],suburb:[]},drivers:[]};let currentSection=null,headerFound=false,tableStarted=false,tableEnded=false;for(let i=0;i<sheetData.length;i++){const row=sheetData[i];if(!row||row.length===0)continue;const cells=row.map(c=>String(c).trim());if(!headerFound){const joined=cells.join(' ').toUpperCase();if(joined.includes(VEDOMOST_HEADER)){headerFound=true}continue}if(!result.docNumber){const numIdx=cells.findIndex(c=>c.toUpperCase().includes('ДОКУМЕНТ'));if(numIdx>=0){result.docNumber=cells[numIdx+1]||'';const dateIdx=cells.findIndex(c=>/^\d{2}[./-]\d{2}[./-]\d{4}$/.test(c));if(dateIdx>=0)result.date=cells[dateIdx]}}if(!result.date){const dateMatch=cells.find(c=>/^\d{2}[./-]\d{2}[./-]\d{4}$/.test(c));if(dateMatch)result.date=dateMatch}if(!result.cashier){const cashIdx=cells.findIndex(c=>c.toUpperCase().includes('КАССИР'));if(cashIdx>=0&&cashIdx+1<cells.length&&cells[cashIdx+1].length>2)result.cashier=cells[cashIdx+1]}const joinedRow=cells.join(' ').toUpperCase();if(joinedRow.includes('ГОРОД')||joinedRow.includes('ГОР.')||(joinedRow.includes('ГОР')&&!joinedRow.includes('ПРИГОР'))){currentSection='city';tableStarted=false;continue}if(joinedRow.includes('ПРИГОРОД')||joinedRow.includes('ПРИГ.')){currentSection='suburb';tableStarted=false;continue}if(cells.every(c=>c==='')){if(tableStarted)tableEnded=true;continue}const headerRow=cells.join(' ').toUpperCase();if(headerRow.includes('ФИО')||headerRow.includes('Ф.И.О')||headerRow.includes('ВОДИТЕЛЬ')||headerRow.includes('ФАМИЛИЯ')||(headerRow.includes('ФИО')&&(headerRow.includes('СУММА')||headerRow.includes('МАРШРУТ')))){tableStarted=true;tableEnded=false;continue}if(tableEnded&&!tableStarted)continue;if(tableStarted&&!tableEnded&&currentSection){const driver=parseDriverRow(cells,currentSection);if(driver){result.drivers.push(driver);result.sections[currentSection].push(driver)}}}return result}
function parseDriverRow(cells,section){if(!cells||cells.length<2)return null;const nonEmpty=cells.filter(c=>c.trim()!=='');if(nonEmpty.length<2)return null;const joined=cells.join(' ').toUpperCase();if(joined.includes('ИТОГО')||joined.includes('ВСЕГО')||joined.includes('ПО РАЗДЕЛУ'))return null;const fio=cells[0]||'';if(fio.length<3||/^\d+$/.test(fio))return null;let plate='',plateIdx=-1;for(let j=0;j<cells.length;j++){const cleaned=cells[j].replace(/[\s-]/g,'').toUpperCase();if(/^[А-ЯA-Z]{1}\d{3}[А-ЯA-Z]{2}\d{2,3}$/.test(cleaned)||/^[А-ЯA-Z]{1}\d{3}[А-ЯA-Z]{2}$/.test(cleaned)){plate=cleaned;plateIdx=j;break}}let route='';for(let j=1;j<cells.length;j++){const c=cells[j].trim();if(/^\d{1,3}$/.test(c)&&c!==plate){route=c;break}}let sum=0;for(let j=cells.length-1;j>=0;j--){const c=cells[j].trim().replace(/\s/g,'').replace(',','.');const num=parseFloat(c);if(!isNaN(num)&&num>0&&j!==plateIdx&&j!==0){sum=num;break}}return{fio:normalizeName(fio),plate:normalizePlate(plate),section,route,sum,cash:0,nonCash:0}}
function parseTransactions(sheetData){if(!sheetData||sheetData.length===0)return[];let headerRow=-1;let colIndexes={};for(let i=0;i<Math.min(sheetData.length,10);i++){const row=sheetData[i];if(!row)continue;const cells=row.map(c=>String(c).toUpperCase().trim());const idxDate=cells.findIndex(c=>c.includes('DATE'));const idxConductor=cells.findIndex(c=>c.includes('CONDUCTOR'));const idxTarif=cells.findIndex(c=>c.includes('TARIF_SUM')||(c.includes('TARIF')&&!c.includes('TARIF_PAY')));const idxCar=cells.findIndex(c=>c.includes('CAR')||c.includes('PLATE')||c.includes('AUTO'));const idxRoute=cells.findIndex(c=>c.includes('ROUTE')||c.includes('RUTE'));if(idxDate>=0&&idxConductor>=0&&idxTarif>=0){headerRow=i;colIndexes={date:idxDate,conductor:idxConductor,tarifSum:idxTarif,car:idxCar>=0?idxCar:-1,route:idxRoute>=0?idxRoute:-1};break}}if(headerRow<0)return[];const transactions=[];for(let i=headerRow+1;i<sheetData.length;i++){const row=sheetData[i];if(!row||row.length===0)continue;const date=String(row[colIndexes.date]||'').trim();const conductor=String(row[colIndexes.conductor]||'').trim();const tarifSumStr=String(row[colIndexes.tarifSum]||'0').trim().replace(',','.');const tarifSum=parseFloat(tarifSumStr);if(!date||!conductor||isNaN(tarifSum))continue;if(conductor.toUpperCase().includes('CONDUCTOR'))continue;const car=colIndexes.car>=0?String(row[colIndexes.car]||'').trim():'';const route=colIndexes.route>=0?String(row[colIndexes.route]||'').trim():'';transactions.push({date,conductor:normalizeName(conductor),plate:normalizePlate(car),route,tarifSum,tarifRub:tarifSum/100})}return transactions}
function reconcile(vedomosti,transactions){const vedomostDates=new Set();vedomosti.forEach(v=>{if(v.date)vedomostDates.add(normalizeDate(v.date))});const filteredTransactions=transactions.filter(t=>vedomostDates.has(normalizeDate(t.date)));const txByDriver=new Map();filteredTransactions.forEach(t=>{const key=t.conductor+'|'+(t.plate||'');if(!txByDriver.has(key))txByDriver.set(key,{conductor:t.conductor,plate:t.plate,totalTarif:0,txCount:0,dates:new Set()});const entry=txByDriver.get(key);entry.totalTarif+=t.tarifRub;entry.txCount++;entry.dates.add(normalizeDate(t.date))});const driverMap=new Map();vedomosti.forEach(v=>{v.drivers.forEach(d=>{const key=d.fio+'|'+d.plate;if(!driverMap.has(key))driverMap.set(key,{fio:d.fio,plate:d.plate,route:d.route,section:d.section,cashier:v.cashier,vedomostDate:v.date,totalGiven:0});driverMap.get(key).totalGiven+=d.sum})});const results=[];const unmatchedTx=[];const usedTxKeys=new Set();driverMap.forEach((vd,key)=>{const txKey=vd.fio+'|'+vd.plate;let txEntry=txByDriver.get(txKey);if(!txEntry){let bestSim=0,bestKey='';txByDriver.forEach((entry,k)=>{const[txFio,txPlate]=k.split('|');const plateMatch=txPlate&&vd.plate&&normalizePlate(txPlate)===normalizePlate(vd.plate);const sim=nameSimilarity(vd.fio,txFio);if(plateMatch&&sim>=FUZZY_THRESHOLD&&sim>bestSim){bestSim=sim;bestKey=k}});if(bestKey){txEntry=txByDriver.get(bestKey);usedTxKeys.add(bestKey)}}else{usedTxKeys.add(txKey)}const totalCollected=txEntry?txEntry.totalTarif:0;const diff=txEntry?Math.round(totalCollected-vd.totalGiven):-vd.totalGiven;results.push({driver:vd.fio,car:vd.plate||'—',route:vd.route||'—',section:vd.section==='city'?'Город':'Пригород',cashier:vd.cashier||'—',date:vd.vedomostDate||'—',given:Math.round(vd.totalGiven),collected:Math.round(totalCollected),diff,txCount:txEntry?txEntry.txCount:0,status:diff===0?'ok':(Math.abs(diff)<=10?'warn':'err')})});txByDriver.forEach((entry,key)=>{if(!usedTxKeys.has(key))unmatchedTx.push({driver:entry.conductor,plate:entry.plate||'—',totalCollected:Math.round(entry.totalTarif),txCount:entry.txCount})});const cashierMap=new Map();results.forEach(r=>{const key=r.cashier;if(!cashierMap.has(key))cashierMap.set(key,{cashier:key,count:0,given:0,collected:0,diff:0});const c=cashierMap.get(key);c.count++;c.given+=r.given;c.collected+=r.collected;c.diff+=r.diff});const routeMap=new Map();results.forEach(r=>{const key=r.route||'Без маршрута';if(!routeMap.has(key))routeMap.set(key,{route:key,type:r.section,drivers:0,sum:0});const rm=routeMap.get(key);rm.drivers++;rm.sum+=r.given});const totalGiven=results.reduce((s,r)=>s+r.given,0);const totalCollected=results.reduce((s,r)=>s+r.collected,0);const totalDiff=results.reduce((s,r)=>s+r.diff,0);const discrepancies=results.filter(r=>r.diff!==0);return{drivers:results,cashiers:Array.from(cashierMap.values()),routes:Array.from(routeMap.values()),unmatchedTx,conclusions:{totalDrivers:results.length,totalGiven,totalCollected,totalDiff,discrepanciesCount:discrepancies.length,matchedPercent:results.length>0?Math.round((results.length-discrepancies.length)/results.length*100):0}}}
function normalizeDate(dateStr){if(!dateStr)return'';let d=String(dateStr).trim();const serial=parseInt(d);if(!isNaN(serial)&&serial>40000&&serial<60000){const date=new Date((serial-25569)*86400*1000);return date.toISOString().split('T')[0]}const parts=d.split(/[./-]/);if(parts.length===3){let day,month,year;if(parts[0].length===4){year=parts[0];month=parts[1];day=parts[2]}else{day=parts[0];month=parts[1];year=parts[2]}if(year.length===2)year='20'+year;return year+'-'+month.padStart(2,'0')+'-'+day.padStart(2,'0')}return d}
/* ---------- main ---------- */
const state={files:[],fileTypes:{},parsedVedomosti:[],parsedTransactions:[],result:null,isProcessing:false};
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();initTabs();bindEvents();updateUI();showStatus('Загрузите файлы ведомостей и выгрузку транзакций для начала работы','info')});
function bindEvents(){dom.dropzone.addEventListener('click',()=>dom.fileInput.click());dom.dropzone.addEventListener('dragover',e=>{e.preventDefault();dom.dropzone.classList.add('dragover')});dom.dropzone.addEventListener('dragleave',()=>dom.dropzone.classList.remove('dragover'));dom.dropzone.addEventListener('drop',e=>{e.preventDefault();dom.dropzone.classList.remove('dragover');handleFiles(e.dataTransfer.files)});dom.fileInput.addEventListener('change',e=>handleFiles(e.target.files));dom.btnReconcile.addEventListener('click',onReconcile);dom.btnClear.addEventListener('click',onClear);dom.btnExportCsv.addEventListener('click',onExportCsv);dom.btnDownload.addEventListener('click',onDownloadExcel)}
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 hasVed=Object.values(state.fileTypes).includes('vedomost');const hasTx=Object.values(state.fileTypes).includes('transaction');renderFileList();updateUI();if(hasVed&&hasTx)showStatus('Файлы загружены. Нажмите «Провести сверку»','success');else if(hasVed)showStatus('Загружена ведомость. Добавьте выгрузку транзакций','info');else if(hasTx)showStatus('Загружена выгрузка транзакций. Добавьте ведомости','info');else showStatus('Файлы не распознаны. Загрузите ведомости (.xlsx) и/или выгрузку','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);state.fileTypes[i]=type||'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'){dom.fileList.appendChild(renderFileError(file.name,type==='error'?'Ошибка чтения файла':'Не удалось определить тип'))}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]+'</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 dc=c.totalDiff===0?'':(c.totalDiff>0?'err':'warn');dom.conclusionsContent.innerHTML='<div class="summary-card"><div class="info-block '+dc+'"><div class="info-block-icon '+(c.totalDiff===0?'green':(c.totalDiff>0?'rose':'amber'))+'">'+(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:.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'?'Мелкое расхождение':'Расхождение')}));downloadFile(arrayToCsv(data,['driver','car','route','cashier','date','given','collected','diff','status']),'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')}
function updateUI(){const hf=state.files.length>0,hr=state.result!==null;dom.btnReconcile.disabled=!hf||state.isProcessing;dom.btnClear.disabled=!hf||state.isProcessing;dom.btnExportCsv.disabled=!hr;dom.btnDownload.disabled=!hr}
function hideResult(){dom.emptyState.style.display='';dom.resultActions.style.display='none';dom.tabsContainer.style.display='none'}
function showStatus(msg,type){type=type||'info';const bar=dom.statusBar;if(!bar)return;bar.textContent=msg;bar.className='status-bar status-'+type;bar.style.display='';if(type==='success'||type==='info'){clearTimeout(bar._hideTimer);bar._hideTimer=setTimeout(()=>{bar.style.display='none'},8000)}}
</script>
</body>
</html>