Files
contracts-app/site/templates/upload.html
T
naeel cb92f9072e fix(ui): корень /, лого Nubes + favicon, убраны поля номера/клиента/даты
- Страница перенесена с /upload на / (корень)
- Верхняя панель с логотипом Nubes (nubes-logo.svg)
- favicon.png из ipwhitelist-app
- Поля номера/клиента/даты убраны — договор создаётся авто
- Исправлен JS (относительные URL)
2026-06-15 06:35:02 +04:00

535 lines
24 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>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<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;
}
/* Верхняя панель */
.topbar {
background: var(--background); border-bottom: 1px solid var(--brand-gray);
padding: 0 24px; height: 56px;
display: flex; align-items: center; gap: 16px;
}
.topbar img { height: 28px; }
.topbar .title { font-weight: 600; font-size: 16px; }
/* Контент */
.content { max-width: 800px; margin: 32px auto; padding: 0 16px; }
.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; }
.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: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; }
.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="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров</span>
</div>
<div class="content">
<!-- Карточка: Добавление -->
<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">
<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 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 {
const resp = await fetch('/api/contracts', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number: 'б/н ' + new Date().toISOString().slice(0,10)}),
});
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', 'initial');
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>
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>