feat(ui): страница загрузки договора /upload
Дизайн: Nubes Design System (карточки, форма, таблица). Lucide Icons. drag&drop файла docx/pdf/zip. Пайплайн: создание договора → загрузка допника → таблица строк.
This commit is contained in:
@@ -42,6 +42,7 @@ class ContractsApp:
|
|||||||
# -- Системные маршруты --
|
# -- Системные маршруты --
|
||||||
self.app.add_url_rule("/", "index", self._index)
|
self.app.add_url_rule("/", "index", self._index)
|
||||||
self.app.add_url_rule("/health", "health", self._health)
|
self.app.add_url_rule("/health", "health", self._health)
|
||||||
|
self.app.add_url_rule("/upload", "upload_page", self._upload_page)
|
||||||
|
|
||||||
# -- Blueprint-ы (каждый слой — отдельный Blueprint) --
|
# -- Blueprint-ы (каждый слой — отдельный Blueprint) --
|
||||||
self.app.register_blueprint(test_bp)
|
self.app.register_blueprint(test_bp)
|
||||||
@@ -58,6 +59,10 @@ class ContractsApp:
|
|||||||
"""Health check для платформы."""
|
"""Health check для платформы."""
|
||||||
return "OK", 200, {"Content-Type": "text/plain"}
|
return "OK", 200, {"Content-Type": "text/plain"}
|
||||||
|
|
||||||
|
def _upload_page(self):
|
||||||
|
"""Страница загрузки договора (UI)."""
|
||||||
|
return render_template("upload.html")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Запуск Flask (только для разработки, в production — gunicorn)."""
|
"""Запуск Flask (только для разработки, в production — gunicorn)."""
|
||||||
self.app.run(host="0.0.0.0", port=5000)
|
self.app.run(host="0.0.0.0", port=5000)
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Сверка договоров — Загрузка</title>
|
||||||
|
<!-- Lucide Icons -->
|
||||||
|
<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;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container { max-width: 720px; 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;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
background: var(--brand-grey-light);
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.card-body { padding: 16px; }
|
||||||
|
|
||||||
|
/* ── Форма ─────────────────────────────── */
|
||||||
|
.form-table {
|
||||||
|
display: grid;
|
||||||
|
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);
|
||||||
|
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 {
|
||||||
|
height: 32px; border-radius: 6px; gap: 6px; padding: 0 12px;
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/* ── Таблица результатов ───────────────── */
|
||||||
|
.table-wrap {
|
||||||
|
border-radius: 6px; border: 1px solid var(--brand-gray);
|
||||||
|
overflow: hidden; margin-top: 16px;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
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); }
|
||||||
|
|
||||||
|
/* ── Статус ────────────────────────────── */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Карточка: Загрузка -->
|
||||||
|
<div class="card" style="margin-bottom: 16px;">
|
||||||
|
<div class="card-header">
|
||||||
|
<i data-lucide="file-up" style="width:18px;height:18px;"></i>
|
||||||
|
Загрузка договора / допника
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
|
<div class="form-table">
|
||||||
|
<label>Файл (docx, pdf, doc, zip)</label>
|
||||||
|
<input type="file" name="file" accept=".docx,.doc,.pdf,.zip" required>
|
||||||
|
|
||||||
|
<label>Номер договора</label>
|
||||||
|
<input type="text" name="number" placeholder="например: 03700_1">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Карточка: Результат -->
|
||||||
|
<div class="card" id="resultCard" style="display:none;">
|
||||||
|
<div class="card-header">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
const form = document.getElementById('uploadForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const resultCard = document.getElementById('resultCard');
|
||||||
|
const resultTitle = document.getElementById('resultTitle');
|
||||||
|
const summaryBlock = document.getElementById('summaryBlock');
|
||||||
|
const tableBlock = document.getElementById('tableBlock');
|
||||||
|
const rowsTable = document.getElementById('rowsTable');
|
||||||
|
const errorBlock = document.getElementById('errorBlock');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Сброс
|
||||||
|
resultCard.style.display = 'none';
|
||||||
|
errorBlock.style.display = 'none';
|
||||||
|
tableBlock.style.display = 'none';
|
||||||
|
summaryBlock.innerHTML = '';
|
||||||
|
rowsTable.innerHTML = '';
|
||||||
|
|
||||||
|
// Показываем загрузку
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="loader"></span> Обработка...';
|
||||||
|
resultCard.style.display = 'block';
|
||||||
|
resultTitle.textContent = 'Обработка...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const number = formData.get('number');
|
||||||
|
const client = formData.get('client');
|
||||||
|
const dateSigned = formData.get('date_signed');
|
||||||
|
const suppType = formData.get('type');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Создать или найти договор
|
||||||
|
let contractId;
|
||||||
|
if (number) {
|
||||||
|
// Ищем существующий
|
||||||
|
const listResp = await fetch('/api/contracts');
|
||||||
|
const list = await listResp.json();
|
||||||
|
const existing = (list.contracts || []).find(c => c.number === number);
|
||||||
|
if (existing) {
|
||||||
|
contractId = existing.id;
|
||||||
|
} else {
|
||||||
|
// Создаём новый
|
||||||
|
const createResp = await fetch('/api/contracts', {
|
||||||
|
method: 'POST',
|
||||||
|
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();
|
||||||
|
contractId = list2.contracts[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Загрузить допник
|
||||||
|
const suppForm = new FormData();
|
||||||
|
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', {
|
||||||
|
method: 'POST',
|
||||||
|
body: suppForm,
|
||||||
|
});
|
||||||
|
const result = await suppResp.json();
|
||||||
|
|
||||||
|
if (!suppResp.ok) {
|
||||||
|
throw new Error(result.error || result.details?.error || 'Ошибка обработки');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Показать результат
|
||||||
|
resultTitle.innerHTML = '<i data-lucide="check" style="width:18px;height:18px;color:var(--green);"></i> Обработано';
|
||||||
|
|
||||||
|
// Сводка
|
||||||
|
const s = result.diff_summary || {};
|
||||||
|
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 hist = await histResp.json();
|
||||||
|
const rows = (hist.history || []).filter(h => h.change_type === 'added');
|
||||||
|
if (rows.length > 0) {
|
||||||
|
tableBlock.style.display = 'block';
|
||||||
|
rows.slice(-result.rows_extracted).forEach(r => {
|
||||||
|
const v = r.new_values || {};
|
||||||
|
rowsTable.innerHTML +=
|
||||||
|
'<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>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
errorBlock.style.display = 'block';
|
||||||
|
errorBlock.textContent = '❌ ' + err.message;
|
||||||
|
resultTitle.innerHTML = '<i data-lucide="x" style="width:18px;height:18px;color:var(--destructive);"></i> Ошибка';
|
||||||
|
lucide.createIcons();
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i data-lucide="upload" style="width:16px;height:16px;"></i> Загрузить и обработать';
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user