Files
contracts-app/site/templates/upload.html
T
naeel a2affd5be2 fix(ui): двухшаговый UI — очередь файлов → кнопка Обработать
Шаг 1: добавить файлы в очередь (можно несколько)
Шаг 2: нажать «Обработать все файлы»
Прогресс-бар, результаты по каждому файлу, таблица строк.
2026-06-15 06:18:29 +04:00

288 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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://unpkg.com/lucide@latest"></script>
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--background: #ffffff;
--foreground: #1a1a1a;
--muted: #6b7280;
--destructive: #ef4444;
--green: #22c55e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px; color: var(--foreground);
background: var(--brand-grey-light);
min-height: 100vh; padding: 40px 16px;
display: flex; justify-content: center;
}
.container { max-width: 800px; width: 100%; }
.card {
background: var(--background); border-radius: 12px;
border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05);
overflow: hidden; margin-bottom: 16px;
}
.card-header {
background: var(--brand-grey-light); padding: 12px 16px;
font-weight: 600; font-size: 16px;
display: flex; align-items: center; gap: 8px;
}
.card-body { padding: 16px; }
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 14px; line-height: 1; margin-left: 4px; }
.form-group input, .form-group select {
height: 36px; border-radius: 6px; border: 1px solid var(--brand-gray);
padding: 0 12px; font-size: 14px; font-family: inherit;
}
.btn {
height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px;
font-size: 14px; font-family: inherit; cursor: pointer;
display: inline-flex; align-items: center;
border: 1px solid var(--brand-gray); background: var(--background);
}
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn-destructive { background: var(--destructive); border-color: var(--destructive); color: #fff; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.queue { margin-top: 12px; }
.queue-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 6px;
border: 1px solid var(--brand-gray);
background: var(--brand-grey-light); margin-bottom: 4px;
}
.queue-item .name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-item .size { color: var(--muted); font-size: 12px; }
.queue-item .remove { cursor: pointer; color: var(--muted); padding: 4px; }
.queue-item .remove:hover { color: var(--destructive); }
.queue-empty { color: var(--muted); padding: 12px; text-align: center; }
.progress-bar {
height: 4px; background: var(--brand-grey-light);
border-radius: 2px; margin-top: 12px; overflow: hidden;
}
.progress-fill { height: 100%; background: var(--brand-primary); border-radius: 2px; transition: width .3s; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 4px 8px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; }
td { padding: 4px 8px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:hover td { background: rgba(243,244,246,.5); }
.summary { margin-top: 12px; font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; }
.summary span { display: flex; align-items: center; gap: 4px; }
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.error-box { margin-top: 12px; padding: 12px; border-radius: 6px; border: 1px solid var(--destructive); background: rgba(239,68,68,.05); color: var(--destructive); font-size: 14px; }
.badge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 8px; border-radius: 4px; }
.badge-ok { background: rgba(34,197,94,.1); color: var(--green); }
.badge-err { background: rgba(239,68,68,.1); color: var(--destructive); }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
Добавление договоров / допников
</div>
<div class="card-body">
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group">
<label>Номер договора</label>
<input type="text" id="contractNumber" placeholder="03700_1" style="width:160px;">
</div>
<div class="form-group">
<label>Клиент</label>
<input type="text" id="contractClient" placeholder="ЗАО XXX001" style="width:200px;">
</div>
<div class="form-group">
<label>Дата</label>
<input type="date" id="contractDate" style="width:150px;">
</div>
</div>
<div class="form-row">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="height:36px;">
<button class="btn btn-primary" onclick="addFiles()">
<i data-lucide="plus" style="width:16px;height:16px;"></i> Добавить
</button>
</div>
<div class="queue" id="queue"></div>
<div class="queue-empty" id="queueEmpty">Нет добавленных файлов</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" id="processBtn" onclick="processAll()" style="width:100%;justify-content:center;">
<i data-lucide="play" style="width:16px;height:16px;"></i> Обработать все файлы
</button>
</div>
<div class="progress-bar" id="progressBar" style="display:none;">
<div class="progress-fill" id="progressFill" style="width:0%;"></div>
</div>
</div>
</div>
<div class="card" id="resultsCard" style="display:none;">
<div class="card-header">
<i data-lucide="file-text" style="width:18px;height:18px;"></i>
Результаты обработки
</div>
<div class="card-body" id="resultsBody"></div>
</div>
</div>
<script>
lucide.createIcons();
let fileQueue = [];
function addFiles() {
const input = document.getElementById('fileInput');
for (const f of input.files) {
if (!fileQueue.find(q => q.name === f.name && q.size === f.size)) {
fileQueue.push(f);
}
}
input.value = '';
renderQueue();
}
function removeFile(index) { fileQueue.splice(index, 1); renderQueue(); }
function renderQueue() {
const q = document.getElementById('queue');
const empty = document.getElementById('queueEmpty');
if (fileQueue.length === 0) {
q.innerHTML = ''; empty.style.display = 'block';
} else {
empty.style.display = 'none';
q.innerHTML = fileQueue.map((f, i) =>
'<div class="queue-item"><i data-lucide="file-text" style="width:16px;height:16px;color:var(--muted);"></i>' +
'<span class="name">' + f.name + '</span>' +
'<span class="size">' + formatSize(f.size) + '</span>' +
'<span class="remove" onclick="removeFile('+i+')"><i data-lucide="x" style="width:14px;height:14px;"></i></span></div>'
).join('');
lucide.createIcons();
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
async function processAll() {
if (fileQueue.length === 0) return;
const number = document.getElementById('contractNumber').value.trim();
const client = document.getElementById('contractClient').value.trim();
const dateSigned = document.getElementById('contractDate').value;
const processBtn = document.getElementById('processBtn');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const resultsCard = document.getElementById('resultsCard');
const resultsBody = document.getElementById('resultsBody');
processBtn.disabled = true;
processBtn.innerHTML = '<span class="loader"></span> Обработка...';
progressBar.style.display = 'block';
resultsCard.style.display = 'block';
resultsBody.innerHTML = '';
let contractId;
try {
if (number) {
const listResp = await fetch('/api/contracts');
const list = await listResp.json();
const existing = (list.contracts || []).find(c => c.number === number);
contractId = existing ? existing.id : null;
}
if (!contractId) {
await fetch('/api/contracts', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number: number || ('б/н ' + Date.now()), client, date_signed: dateSigned || undefined}),
});
const listResp = await fetch('/api/contracts');
contractId = (await listResp.json()).contracts[0].id;
}
} catch (e) {
resultsBody.innerHTML = '<div class="error-box">❌ ' + e.message + '</div>';
resetBtn(); return;
}
let totalRows = 0;
let results = [];
for (let i = 0; i < fileQueue.length; i++) {
const file = fileQueue[i];
progressFill.style.width = Math.round((i / fileQueue.length) * 100) + '%';
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'amendment');
try {
const resp = await fetch('/api/contracts/' + contractId + '/supplements', { method: 'POST', body: formData });
const data = await resp.json();
if (resp.ok) {
totalRows += data.rows_extracted || 0;
results.push({name: file.name, status: 'ok', rows: data.rows_extracted || 0, summary: data.diff_summary || {}});
} else {
results.push({name: file.name, status: 'error', error: data.error || data.details?.error || 'Ошибка'});
}
} catch (e) {
results.push({name: file.name, status: 'error', error: e.message});
}
}
progressFill.style.width = '100%';
resultsBody.innerHTML =
'<div class="summary" style="margin-bottom:12px;">' +
'<span>Файлов: ' + fileQueue.length + '</span>' +
'<span>Строк извлечено: ' + totalRows + '</span></div>' +
results.map(r =>
'<div class="queue-item">' +
'<span class="badge ' + (r.status === 'ok' ? 'badge-ok' : 'badge-err') + '">' +
(r.status === 'ok' ? '✅ OK' : '❌ Ошибка') + '</span>' +
'<span class="name">' + r.name + '</span>' +
(r.status === 'ok'
? '<span class="size">строк: ' + r.rows + ', +' + (r.summary.added||0) + ' ~' + (r.summary.changed||0) + ' -' + (r.summary.deleted||0) + '</span>'
: '<span class="size" style="color:var(--destructive);">' + r.error + '</span>') +
'</div>'
).join('');
// История
try {
const histResp = await fetch('/api/contracts/' + contractId + '/history');
const hist = await histResp.json();
const rows = (hist.history || []).slice(-totalRows);
if (rows.length > 0) {
resultsBody.innerHTML +=
'<div class="table-wrap" style="margin-top:12px;"><table><thead><tr>' +
'<th>№</th><th>Услуга</th><th>Цена, руб</th><th>Кол-во</th><th>Сумма, руб</th><th>Дата</th></tr></thead><tbody>' +
rows.map(r => {
const v = r.new_values || {};
return '<tr><td>' + (r.row_num||'') + '</td><td>' + (v.name||'').substring(0,80) + '</td>' +
'<td style="text-align:right">' + (v.price ? Number(v.price).toLocaleString() : '') + '</td>' +
'<td style="text-align:center">' + (v.qty||'') + '</td>' +
'<td style="text-align:right">' + (v.sum ? Number(v.sum).toLocaleString() : '') + '</td>' +
'<td>' + (v.date_start||'') + '</td></tr>';
}).join('') + '</tbody></table></div>';
}
} catch(e) {}
resetBtn(); lucide.createIcons();
}
function resetBtn() {
const btn = document.getElementById('processBtn');
btn.disabled = false;
btn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Обработать все файлы';
lucide.createIcons();
}
</script>
</body>
</html>