feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push
- BOM paste: auto-detect columns by content (price, qty, PN, description); handles $5,114.00 and European comma-decimal formats - LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents; oninput updates data only (no re-render), onchange validates+resolves - BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string (GORM Update does not reliably call driver.Valuer for custom types) - BOM autosave after every resolveBOM() call - Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all resolved LOTs directly — Estimate prices shown even before cart apply - Unresolved PNs pushed to qt_vendor_partnumber_seen via POST /api/sync/partnumber-seen (fire-and-forget from JS) - sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at - partnumber_books: pull ALL books (not only is_active=1); re-pull items when header exists but item count is 0; fallback for missing description column - partnumber_books UI: collapsible snapshot section (collapsed by default), pagination (10/page), sync button always visible in header - vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed original_username from WHERE — GetUsername returns "" without JWT) - bible/09-vendor-spec.md: updated with all architectural decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,7 @@
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
|
||||
@@ -59,50 +54,96 @@
|
||||
</div>
|
||||
|
||||
<!-- All books list (collapsed by default) -->
|
||||
<details class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<summary class="px-4 py-3 cursor-pointer text-sm font-medium text-gray-700 hover:bg-gray-50 select-none">
|
||||
История снимков
|
||||
</summary>
|
||||
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</div>
|
||||
<table id="books-table" class="w-full text-sm hidden">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">Версия</th>
|
||||
<th class="px-4 py-2 text-left">Дата</th>
|
||||
<th class="px-4 py-2 text-right">Позиций</th>
|
||||
<th class="px-4 py-2 text-center">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="books-table-body"></tbody>
|
||||
</table>
|
||||
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
|
||||
Нет загруженных снимков.
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
Снимки сопоставлений (Partnumber Books)
|
||||
</button>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<!-- Collapsible body -->
|
||||
<div id="books-section-body" class="hidden border-t">
|
||||
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</div>
|
||||
<table id="books-table" class="w-full text-sm hidden">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">Версия</th>
|
||||
<th class="px-4 py-2 text-left">Дата</th>
|
||||
<th class="px-4 py-2 text-right">Позиций</th>
|
||||
<th class="px-4 py-2 text-center">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="books-table-body"></tbody>
|
||||
</table>
|
||||
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
|
||||
Нет загруженных снимков.
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="books-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
|
||||
<span id="books-page-info"></span>
|
||||
<div class="flex gap-2">
|
||||
<button id="books-prev" onclick="changeBooksPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
|
||||
<button id="books-next" onclick="changeBooksPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
|
||||
<script>
|
||||
let allItems = [];
|
||||
let allBooks = [];
|
||||
let booksPage = 1;
|
||||
const BOOKS_PER_PAGE = 10;
|
||||
|
||||
function toggleBooksSection() {
|
||||
const body = document.getElementById('books-section-body');
|
||||
const chevron = document.getElementById('books-chevron');
|
||||
const collapsed = body.classList.toggle('hidden');
|
||||
chevron.style.transform = collapsed ? '' : 'rotate(90deg)';
|
||||
}
|
||||
|
||||
async function loadBooks() {
|
||||
const resp = await fetch('/api/partnumber-books');
|
||||
const data = await resp.json();
|
||||
const books = data.books || [];
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch('/api/partnumber-books');
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
document.getElementById('books-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
allBooks = data.books || [];
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
|
||||
if (!books.length) {
|
||||
if (!allBooks.length) {
|
||||
document.getElementById('books-empty').classList.remove('hidden');
|
||||
document.getElementById('summary-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill history table
|
||||
booksPage = 1;
|
||||
renderBooksPage();
|
||||
|
||||
const active = allBooks.find(b => b.is_active) || allBooks[0];
|
||||
await loadActiveBookItems(active);
|
||||
}
|
||||
|
||||
function renderBooksPage() {
|
||||
const total = allBooks.length;
|
||||
const totalPages = Math.ceil(total / BOOKS_PER_PAGE);
|
||||
const start = (booksPage - 1) * BOOKS_PER_PAGE;
|
||||
const pageBooks = allBooks.slice(start, start + BOOKS_PER_PAGE);
|
||||
|
||||
const tbody = document.getElementById('books-table-body');
|
||||
tbody.innerHTML = '';
|
||||
books.forEach(b => {
|
||||
pageBooks.forEach(b => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50';
|
||||
tr.innerHTML = `
|
||||
@@ -119,17 +160,36 @@ async function loadBooks() {
|
||||
});
|
||||
document.getElementById('books-table').classList.remove('hidden');
|
||||
|
||||
// Load active book detail
|
||||
const active = books.find(b => b.is_active) || books[0];
|
||||
await loadActiveBookItems(active);
|
||||
// Pagination controls
|
||||
if (total > BOOKS_PER_PAGE) {
|
||||
document.getElementById('books-pagination').classList.remove('hidden');
|
||||
document.getElementById('books-page-info').textContent =
|
||||
`Снимки ${start + 1}–${Math.min(start + BOOKS_PER_PAGE, total)} из ${total}`;
|
||||
document.getElementById('books-prev').disabled = booksPage === 1;
|
||||
document.getElementById('books-next').disabled = booksPage === totalPages;
|
||||
} else {
|
||||
document.getElementById('books-pagination').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function changeBooksPage(delta) {
|
||||
const totalPages = Math.ceil(allBooks.length / BOOKS_PER_PAGE);
|
||||
booksPage = Math.max(1, Math.min(totalPages, booksPage + delta));
|
||||
renderBooksPage();
|
||||
}
|
||||
|
||||
async function loadActiveBookItems(book) {
|
||||
const resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
const data = await resp.json();
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
|
||||
allItems = data.items || [];
|
||||
|
||||
// Compute stats
|
||||
const lots = new Set(allItems.map(i => i.lot_name));
|
||||
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
|
||||
|
||||
@@ -170,17 +230,21 @@ function filterItems(query) {
|
||||
}
|
||||
|
||||
async function syncPartnumberBooks() {
|
||||
let resp, data;
|
||||
try {
|
||||
const resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
|
||||
loadBooks();
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'error');
|
||||
}
|
||||
resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
showToast('Ошибка синхронизации', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.success) {
|
||||
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
|
||||
loadBooks();
|
||||
} else if (data.blocked) {
|
||||
showToast(`Синк заблокирован: ${data.reason_text}`, 'error');
|
||||
} else {
|
||||
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +253,4 @@ document.addEventListener('DOMContentLoaded', loadBooks);
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user