Files
contracts-app/site/templates/upload.html
T
naeel 54d9924c04 feat(ui): страница загрузки договора /upload
Дизайн: Nubes Design System (карточки, форма, таблица).
Lucide Icons. drag&drop файла docx/pdf/zip.
Пайплайн: создание договора → загрузка допника → таблица строк.
2026-06-15 06:15:12 +04:00

328 lines
12 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>
<!-- 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>