Fix stock import UI bugs: dead code, fragile data attr, double-click, silent duplicates

- Remove unused stockMappingsCache variable (dead code after selectStockMappingRow removal)
- Move data-description from SVG to button element for reliable access
- Add disabled guard on bulk add/ignore buttons to prevent duplicate requests
- Return explicit error in UpsertIgnoreRule when rule already exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 23:30:01 +03:00
parent 5f2969a85a
commit 72ff842f5d
2 changed files with 72 additions and 53 deletions

View File

@@ -114,13 +114,13 @@
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-amber-900">Новые партномера без сопоставления</div>
<div class="flex items-center gap-2">
<button onclick="addAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-blue-200 text-blue-700 hover:bg-blue-50" title="Добавить все">
<button id="btn-add-all-suggestions" onclick="addAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-blue-200 text-blue-700 hover:bg-blue-50" title="Добавить все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Добавить все</span>
</button>
<button onclick="ignoreAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-amber-200 text-amber-700 hover:bg-amber-100" title="Игнорировать все">
<button id="btn-ignore-all-suggestions" onclick="ignoreAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-amber-200 text-amber-700 hover:bg-amber-100" title="Игнорировать все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
@@ -374,7 +374,6 @@ let isCreatingPricelist = false;
let cachedDbUsername = null;
let syncUsersStatusTimer = null;
let stockMappingsPage = 1;
let stockMappingsCache = [];
let stockIgnoreRulesPage = 1;
let stockMappingsSearch = '';
let stockMappingsSearchTimer = null;
@@ -1201,8 +1200,8 @@ function renderStockImportSuggestions(items) {
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(formatSuggestionReason(item.reason || 'unmapped'))}</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="addSuggestionMapping(this)" class="text-blue-600 hover:text-blue-800" title="Добавить">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-description="${encodeURIComponent(item.description || '')}">
<button data-partnumber="${escapeHtml(item.partnumber || '')}" data-description="${encodeURIComponent(item.description || '')}" onclick="addSuggestionMapping(this)" class="text-blue-600 hover:text-blue-800" title="Добавить">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
@@ -1221,7 +1220,7 @@ function renderStockImportSuggestions(items) {
async function addSuggestionMapping(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
const description = decodeURIComponent(button?.querySelector('svg')?.dataset?.description || '');
const description = decodeURIComponent(button?.dataset?.description || '');
const input = document.querySelector(`input[data-role="suggestion-lot"][data-partnumber="${CSS.escape(partnumber)}"]`);
const lotName = (input?.value || '').trim();
try {
@@ -1262,56 +1261,70 @@ async function ignoreSuggestion(button) {
}
async function addAllSuggestions() {
const descriptionByPartnumber = {};
for (const s of stockImportSuggestions) {
const pn = (s.partnumber || '').trim().toLowerCase();
if (!pn) continue;
descriptionByPartnumber[pn] = s.description || '';
const btn = document.getElementById('btn-add-all-suggestions');
if (btn?.disabled) return;
if (btn) btn.disabled = true;
try {
const descriptionByPartnumber = {};
for (const s of stockImportSuggestions) {
const pn = (s.partnumber || '').trim().toLowerCase();
if (!pn) continue;
descriptionByPartnumber[pn] = s.description || '';
}
const inputs = Array.from(document.querySelectorAll('input[data-role="suggestion-lot"]'));
const tasks = inputs
.map(input => ({
partnumber: (input.dataset.partnumber || '').trim(),
lot: (input.value || '').trim(),
description: descriptionByPartnumber[((input.dataset.partnumber || '').trim().toLowerCase())] || ''
}))
.filter(x => x.partnumber);
if (tasks.length === 0) {
showToast('Нет строк для добавления', 'error');
return;
}
let ok = 0;
for (const t of tasks) {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: t.partnumber, lot_name: t.lot, description: t.description })
});
if (resp.ok) ok++;
}
stockImportSuggestions = stockImportSuggestions.filter(item => !tasks.some(t => t.partnumber.toLowerCase() === (item.partnumber || '').toLowerCase()));
renderStockImportSuggestions(stockImportSuggestions);
await loadStockMappings(1);
showToast(`Добавлено: ${ok}`, 'success');
} finally {
if (btn) btn.disabled = false;
}
const inputs = Array.from(document.querySelectorAll('input[data-role="suggestion-lot"]'));
const tasks = inputs
.map(input => ({
partnumber: (input.dataset.partnumber || '').trim(),
lot: (input.value || '').trim(),
description: descriptionByPartnumber[((input.dataset.partnumber || '').trim().toLowerCase())] || ''
}))
.filter(x => x.partnumber);
if (tasks.length === 0) {
showToast('Нет строк для добавления', 'error');
return;
}
let ok = 0;
for (const t of tasks) {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: t.partnumber, lot_name: t.lot, description: t.description })
});
if (resp.ok) ok++;
}
stockImportSuggestions = stockImportSuggestions.filter(item => !tasks.some(t => t.partnumber.toLowerCase() === (item.partnumber || '').toLowerCase()));
renderStockImportSuggestions(stockImportSuggestions);
await loadStockMappings(1);
showToast(`Добавлено: ${ok}`, 'success');
}
async function ignoreAllSuggestions() {
if (!stockImportSuggestions.length) return;
let ok = 0;
for (const s of stockImportSuggestions) {
const partnumber = (s.partnumber || '').trim();
if (!partnumber) continue;
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
});
if (resp.ok) ok++;
const btn = document.getElementById('btn-ignore-all-suggestions');
if (btn?.disabled) return;
if (btn) btn.disabled = true;
try {
let ok = 0;
for (const s of stockImportSuggestions) {
const partnumber = (s.partnumber || '').trim();
if (!partnumber) continue;
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
});
if (resp.ok) ok++;
}
stockImportSuggestions = [];
renderStockImportSuggestions(stockImportSuggestions);
await loadStockIgnoreRules(1);
showToast(`Добавлено в игнорирование: ${ok}`, 'success');
} finally {
if (btn) btn.disabled = false;
}
stockImportSuggestions = [];
renderStockImportSuggestions(stockImportSuggestions);
await loadStockIgnoreRules(1);
showToast(`Добавлено в игнорирование: ${ok}`, 'success');
}
async function loadStockMappings(page = 1) {
@@ -1325,7 +1338,6 @@ async function loadStockMappings(page = 1) {
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || [];
stockMappingsCache = items;
if (items.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
} else {