fix(ui): двухшаговый UI — очередь файлов → кнопка Обработать

Шаг 1: добавить файлы в очередь (можно несколько)
Шаг 2: нажать «Обработать все файлы»
Прогресс-бар, результаты по каждому файлу, таблица строк.
This commit is contained in:
2026-06-15 06:18:29 +04:00
parent 54d9924c04
commit a2affd5be2
+197 -237
View File
@@ -3,8 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка договоров — Загрузка</title> <title>Сверка договоров</title>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<style> <style>
:root { :root {
@@ -18,310 +17,271 @@
--destructive: #ef4444; --destructive: #ef4444;
--green: #22c55e; --green: #22c55e;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px; font-size: 14px; color: var(--foreground);
color: var(--foreground);
background: var(--brand-grey-light); background: var(--brand-grey-light);
min-height: 100vh; min-height: 100vh; padding: 40px 16px;
display: flex; display: flex; justify-content: center;
justify-content: center;
padding: 40px 16px;
} }
.container { max-width: 800px; width: 100%; }
.container { max-width: 720px; width: 100%; }
/* ── Карточка ──────────────────────────── */
.card { .card {
background: var(--background); background: var(--background); border-radius: 12px;
border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05);
border: 1px solid var(--brand-gray); overflow: hidden; margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
overflow: hidden;
} }
.card-header { .card-header {
background: var(--brand-grey-light); background: var(--brand-grey-light); padding: 12px 16px;
padding: 12px; font-weight: 600; font-size: 16px;
font-weight: 600;
font-size: 16px;
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
} }
.card-body { padding: 16px; } .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-table { .form-group label { font-size: 14px; line-height: 1; margin-left: 4px; }
display: grid; .form-group input, .form-group select {
grid-template-columns: fit-content(200px) minmax(200px, 1fr);
gap: 8px 16px;
align-items: center;
}
label {
font-size: 14px; line-height: 1; font-weight: 400;
margin-bottom: 4px; margin-left: 4px;
}
input, select, textarea {
height: 36px; border-radius: 6px; border: 1px solid var(--brand-gray); height: 36px; border-radius: 6px; border: 1px solid var(--brand-gray);
padding: 0 12px; font-size: 14px; font-family: inherit; padding: 0 12px; font-size: 14px; font-family: inherit;
width: 100%;
} }
textarea { height: auto; padding: 8px 12px; resize: vertical; }
input[type="file"] {
padding: 6px 12px; height: auto;
}
/* ── Кнопка ────────────────────────────── */
.btn { .btn {
height: 32px; border-radius: 6px; gap: 6px; padding: 0 12px; height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px;
font-size: 14px; font-family: inherit; cursor: pointer; font-size: 14px; font-family: inherit; cursor: pointer;
display: inline-flex; align-items: center; display: inline-flex; align-items: center;
border: 1px solid var(--brand-gray); border: 1px solid var(--brand-gray); background: var(--background);
background: var(--background);
}
.btn-primary {
background: var(--brand-primary);
border-color: var(--brand-primary);
color: #fff;
} }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); } .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; } .btn:disabled { opacity: .5; cursor: not-allowed; }
.queue { margin-top: 12px; }
/* ── Таблица результатов ───────────────── */ .queue-item {
.table-wrap { display: flex; align-items: center; gap: 8px;
border-radius: 6px; border: 1px solid var(--brand-gray); padding: 8px 12px; border-radius: 6px;
overflow: hidden; margin-top: 16px; border: 1px solid var(--brand-gray);
background: var(--brand-grey-light); margin-bottom: 4px;
} }
table { width: 100%; border-collapse: collapse; font-size: 14px; } .queue-item .name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
th { .queue-item .size { color: var(--muted); font-size: 12px; }
background: var(--brand-grey-light); text-transform: uppercase; .queue-item .remove { cursor: pointer; color: var(--muted); padding: 4px; }
padding: 4px 8px; border-right: 1px solid var(--brand-gray); .queue-item .remove:hover { color: var(--destructive); }
text-align: left; font-weight: 600; .queue-empty { color: var(--muted); padding: 12px; text-align: center; }
} .progress-bar {
td { height: 4px; background: var(--brand-grey-light);
padding: 4px 8px; border-right: 1px solid var(--brand-gray); border-radius: 2px; margin-top: 12px; overflow: hidden;
border-bottom: 1px solid var(--brand-gray);
} }
.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); } tr:hover td { background: rgba(243,244,246,.5); }
.summary { margin-top: 12px; font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; }
/* ── Статус ────────────────────────────── */
.status-ok { color: var(--green); }
.status-err { color: var(--destructive); }
.summary {
margin-top: 12px; font-size: 14px;
display: flex; gap: 16px; flex-wrap: wrap;
}
.summary span { display: flex; align-items: center; gap: 4px; } .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; }
.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); } } @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; }
.error-box { .badge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 8px; border-radius: 4px; }
margin-top: 12px; padding: 12px; border-radius: 6px; .badge-ok { background: rgba(34,197,94,.1); color: var(--green); }
border: 1px solid var(--destructive); .badge-err { background: rgba(239,68,68,.1); color: var(--destructive); }
background: rgba(239,68,68,.05); color: var(--destructive);
font-size: 14px;
}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="card">
<!-- Карточка: Загрузка -->
<div class="card" style="margin-bottom: 16px;">
<div class="card-header"> <div class="card-header">
<i data-lucide="file-up" style="width:18px;height:18px;"></i> <i data-lucide="folder-open" style="width:18px;height:18px;"></i>
Загрузка договора / допника Добавление договоров / допников
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="uploadForm" enctype="multipart/form-data"> <div class="form-row" style="margin-bottom:12px;">
<div class="form-table"> <div class="form-group">
<label>Файл (docx, pdf, doc, zip)</label>
<input type="file" name="file" accept=".docx,.doc,.pdf,.zip" required>
<label>Номер договора</label> <label>Номер договора</label>
<input type="text" name="number" placeholder="например: 03700_1"> <input type="text" id="contractNumber" placeholder="03700_1" style="width:160px;">
<label>Клиент</label>
<input type="text" name="client" placeholder="например: ЗАО XXX001">
<label>Дата подписания</label>
<input type="date" name="date_signed">
<label>Тип</label>
<select name="type">
<option value="initial">Начальная спецификация</option>
<option value="amendment">Допник (изменение)</option>
</select>
<label></label>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i data-lucide="upload" style="width:16px;height:16px;"></i>
Загрузить и обработать
</button>
</div> </div>
</form> <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> </div>
<!-- Карточка: Результат --> <div class="card" id="resultsCard" style="display:none;">
<div class="card" id="resultCard" style="display:none;">
<div class="card-header"> <div class="card-header">
<i data-lucide="file-text" style="width:18px;height:18px;"></i> <i data-lucide="file-text" style="width:18px;height:18px;"></i>
<span id="resultTitle">Результат обработки</span> Результаты обработки
</div>
<div class="card-body" id="resultBody">
<div id="summaryBlock" class="summary"></div>
<div class="table-wrap" id="tableBlock" style="display:none;">
<table>
<thead>
<tr>
<th></th><th>Услуга</th><th>Цена, руб</th><th>Кол-во</th><th>Сумма, руб</th><th>Дата</th>
</tr>
</thead>
<tbody id="rowsTable"></tbody>
</table>
</div>
<div id="errorBlock" class="error-box" style="display:none;"></div>
</div> </div>
<div class="card-body" id="resultsBody"></div>
</div> </div>
</div> </div>
<script> <script>
lucide.createIcons(); lucide.createIcons();
let fileQueue = [];
const form = document.getElementById('uploadForm'); function addFiles() {
const submitBtn = document.getElementById('submitBtn'); const input = document.getElementById('fileInput');
const resultCard = document.getElementById('resultCard'); for (const f of input.files) {
const resultTitle = document.getElementById('resultTitle'); if (!fileQueue.find(q => q.name === f.name && q.size === f.size)) {
const summaryBlock = document.getElementById('summaryBlock'); fileQueue.push(f);
const tableBlock = document.getElementById('tableBlock'); }
const rowsTable = document.getElementById('rowsTable'); }
const errorBlock = document.getElementById('errorBlock'); input.value = '';
renderQueue();
}
form.addEventListener('submit', async (e) => { function removeFile(index) { fileQueue.splice(index, 1); renderQueue(); }
e.preventDefault();
// Сброс function renderQueue() {
resultCard.style.display = 'none'; const q = document.getElementById('queue');
errorBlock.style.display = 'none'; const empty = document.getElementById('queueEmpty');
tableBlock.style.display = 'none'; if (fileQueue.length === 0) {
summaryBlock.innerHTML = ''; q.innerHTML = ''; empty.style.display = 'block';
rowsTable.innerHTML = ''; } 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) {
submitBtn.disabled = true; if (bytes < 1024) return bytes + ' B';
submitBtn.innerHTML = '<span class="loader"></span> Обработка...'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
resultCard.style.display = 'block'; return (bytes / 1048576).toFixed(1) + ' MB';
resultTitle.textContent = 'Обработка...'; }
const formData = new FormData(form); async function processAll() {
const number = formData.get('number'); if (fileQueue.length === 0) return;
const client = formData.get('client'); const number = document.getElementById('contractNumber').value.trim();
const dateSigned = formData.get('date_signed'); const client = document.getElementById('contractClient').value.trim();
const suppType = formData.get('type'); 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 { try {
// 1. Создать или найти договор
let contractId;
if (number) { if (number) {
// Ищем существующий
const listResp = await fetch('/api/contracts'); const listResp = await fetch('/api/contracts');
const list = await listResp.json(); const list = await listResp.json();
const existing = (list.contracts || []).find(c => c.number === number); const existing = (list.contracts || []).find(c => c.number === number);
if (existing) { contractId = existing ? existing.id : null;
contractId = existing.id; }
} else { if (!contractId) {
// Создаём новый await fetch('/api/contracts', {
const createResp = await fetch('/api/contracts', { method: 'POST', headers: {'Content-Type': 'application/json'},
method: 'POST', body: JSON.stringify({number: number || ('б/н ' + Date.now()), client, date_signed: dateSigned || undefined}),
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number, client, date_signed: dateSigned || undefined}),
});
const created = await createResp.json();
// Получаем ID из списка
const list2 = await (await fetch('/api/contracts')).json();
contractId = list2.contracts[0].id;
}
} else {
// Без номера — создаём без номера
const createResp = await fetch('/api/contracts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number: 'б/н ' + Date.now(), client}),
}); });
const list2 = await (await fetch('/api/contracts')).json(); const listResp = await fetch('/api/contracts');
contractId = list2.contracts[0].id; contractId = (await listResp.json()).contracts[0].id;
} }
} catch (e) {
resultsBody.innerHTML = '<div class="error-box">❌ ' + e.message + '</div>';
resetBtn(); return;
}
// 2. Загрузить допник let totalRows = 0;
const suppForm = new FormData(); let results = [];
suppForm.append('file', formData.get('file'));
if (number) suppForm.append('number', number);
if (dateSigned) suppForm.append('date_signed', dateSigned);
suppForm.append('type', suppType);
const suppResp = await fetch('/api/contracts/' + contractId + '/supplements', { for (let i = 0; i < fileQueue.length; i++) {
method: 'POST', const file = fileQueue[i];
body: suppForm, progressFill.style.width = Math.round((i / fileQueue.length) * 100) + '%';
}); const formData = new FormData();
const result = await suppResp.json(); formData.append('file', file);
formData.append('type', 'amendment');
if (!suppResp.ok) { try {
throw new Error(result.error || result.details?.error || 'Ошибка обработки'); 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%';
// 3. Показать результат resultsBody.innerHTML =
resultTitle.innerHTML = '<i data-lucide="check" style="width:18px;height:18px;color:var(--green);"></i> Обработано'; '<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('');
// Сводка // История
const s = result.diff_summary || {}; try {
summaryBlock.innerHTML =
'<span><i data-lucide="plus" style="width:14px;height:14px;color:var(--green);"></i> Добавлено: ' + (s.added || 0) + '</span>' +
'<span><i data-lucide="pencil" style="width:14px;height:14px;"></i> Изменено: ' + (s.changed || 0) + '</span>' +
'<span><i data-lucide="minus" style="width:14px;height:14px;color:var(--destructive);"></i> Удалено: ' + (s.deleted || 0) + '</span>' +
'<span>Без изменений: ' + (s.unchanged || 0) + '</span>' +
'<span>Строк извлечено: ' + (result.rows_extracted || 0) + '</span>';
// Таблица строк — получаем из истории
const histResp = await fetch('/api/contracts/' + contractId + '/history'); const histResp = await fetch('/api/contracts/' + contractId + '/history');
const hist = await histResp.json(); const hist = await histResp.json();
const rows = (hist.history || []).filter(h => h.change_type === 'added'); const rows = (hist.history || []).slice(-totalRows);
if (rows.length > 0) { if (rows.length > 0) {
tableBlock.style.display = 'block'; resultsBody.innerHTML +=
rows.slice(-result.rows_extracted).forEach(r => { '<div class="table-wrap" style="margin-top:12px;"><table><thead><tr>' +
const v = r.new_values || {}; '<th>№</th><th>Услуга</th><th>Цена, руб</th><th>Кол-во</th><th>Сумма, руб</th><th>Дата</th></tr></thead><tbody>' +
rowsTable.innerHTML += rows.map(r => {
'<tr>' + const v = r.new_values || {};
'<td>' + (r.row_num || '') + '</td>' + return '<tr><td>' + (r.row_num||'') + '</td><td>' + (v.name||'').substring(0,80) + '</td>' +
'<td>' + (v.name || '').substring(0, 80) + '</td>' + '<td style="text-align:right">' + (v.price ? Number(v.price).toLocaleString() : '') + '</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:center">' + (v.qty || '') + '</td>' + '<td style="text-align:right">' + (v.sum ? Number(v.sum).toLocaleString() : '') + '</td>' +
'<td style="text-align:right">' + (v.sum ? Number(v.sum).toLocaleString() : '') + '</td>' + '<td>' + (v.date_start||'') + '</td></tr>';
'<td>' + (v.date_start || '') + '</td>' + }).join('') + '</tbody></table></div>';
'</tr>';
});
} }
} catch(e) {}
lucide.createIcons(); resetBtn(); lucide.createIcons();
}
} catch (err) { function resetBtn() {
errorBlock.style.display = 'block'; const btn = document.getElementById('processBtn');
errorBlock.textContent = '❌ ' + err.message; btn.disabled = false;
resultTitle.innerHTML = '<i data-lucide="x" style="width:18px;height:18px;color:var(--destructive);"></i> Ошибка'; btn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Обработать все файлы';
lucide.createIcons(); lucide.createIcons();
} finally { }
submitBtn.disabled = false;
submitBtn.innerHTML = '<i data-lucide="upload" style="width:16px;height:16px;"></i> Загрузить и обработать';
lucide.createIcons();
}
});
</script> </script>
</body> </body>
</html> </html>