Refine stock import UX with suggestions, ignore rules, and inline mapping controls
This commit is contained in:
@@ -110,23 +110,55 @@
|
||||
</div>
|
||||
<div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div>
|
||||
</div>
|
||||
<div id="stock-import-suggestions" class="hidden mt-4 p-3 bg-amber-50 border border-amber-200 rounded">
|
||||
<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="Добавить все">
|
||||
<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="Игнорировать все">
|
||||
<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>
|
||||
<span>Игнорировать все</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-amber-200">
|
||||
<thead class="bg-amber-100">
|
||||
<tr>
|
||||
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">LOT</th>
|
||||
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">Partnumber</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Описание</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Причина</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-amber-800 uppercase">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stock-import-suggestions-body" class="bg-white divide-y divide-amber-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input id="mapping-partnumber" type="text" placeholder="partnumber" class="px-3 py-2 border rounded w-1/3">
|
||||
<input id="mapping-lotname" type="text" placeholder="lot_name" class="px-3 py-2 border rounded w-1/3">
|
||||
<button onclick="saveStockMapping()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||
<input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
|
||||
<datalist id="stock-lot-options"></datalist>
|
||||
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Partnumber</th>
|
||||
<th class="px-4 py-2 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||
<th class="px-4 py-2 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">Partnumber</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -137,6 +169,40 @@
|
||||
</div>
|
||||
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3">Игнорирование при импорте</h3>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<select id="ignore-target" class="px-3 py-2 border rounded">
|
||||
<option value="partnumber">Partnumber</option>
|
||||
<option value="description">Описание</option>
|
||||
</select>
|
||||
<select id="ignore-match-type" class="px-3 py-2 border rounded">
|
||||
<option value="exact">Равно</option>
|
||||
<option value="prefix">Начинается с</option>
|
||||
<option value="suffix">Заканчивается на</option>
|
||||
</select>
|
||||
<input id="ignore-pattern" type="text" placeholder="Шаблон" class="px-3 py-2 border rounded w-1/3">
|
||||
<button onclick="saveStockIgnoreRule()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Добавить</button>
|
||||
<button onclick="loadStockIgnoreRules(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Поле</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Шаблон</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stock-ignore-rules-body" class="bg-white divide-y divide-gray-200">
|
||||
<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="stock-ignore-rules-pagination" class="flex justify-center space-x-2 mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,6 +375,10 @@ let cachedDbUsername = null;
|
||||
let syncUsersStatusTimer = null;
|
||||
let stockMappingsPage = 1;
|
||||
let stockMappingsCache = [];
|
||||
let stockIgnoreRulesPage = 1;
|
||||
let stockMappingsSearch = '';
|
||||
let stockMappingsSearchTimer = null;
|
||||
let stockImportSuggestions = [];
|
||||
|
||||
async function loadTab(tab) {
|
||||
currentTab = tab;
|
||||
@@ -341,6 +411,8 @@ async function loadTab(tab) {
|
||||
await loadPricelists(1, currentPricelistSource);
|
||||
if (tab === 'warehouse') {
|
||||
await loadStockMappings(1);
|
||||
await loadStockIgnoreRules(1);
|
||||
await loadStockLotOptions();
|
||||
}
|
||||
} else if (tab === 'component-settings') {
|
||||
document.getElementById('search-bar').className = 'mb-4';
|
||||
@@ -1046,7 +1118,9 @@ async function importStockFile() {
|
||||
const percentEl = document.getElementById('stock-import-percent');
|
||||
const barEl = document.getElementById('stock-import-bar');
|
||||
const statsEl = document.getElementById('stock-import-stats');
|
||||
const suggestionsBox = document.getElementById('stock-import-suggestions');
|
||||
box.classList.remove('hidden');
|
||||
suggestionsBox.classList.add('hidden');
|
||||
statusEl.textContent = 'Запуск импорта...';
|
||||
percentEl.textContent = '0%';
|
||||
barEl.style.width = '0%';
|
||||
@@ -1082,16 +1156,19 @@ async function importStockFile() {
|
||||
statusEl.textContent = data.message || data.status || 'Обработка';
|
||||
statsEl.textContent =
|
||||
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
|
||||
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0)} | ` +
|
||||
`Конфликты: ${data.conflicts || 0}`;
|
||||
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` +
|
||||
`Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`;
|
||||
|
||||
if (data.status === 'error') {
|
||||
throw new Error(data.message || 'Ошибка импорта');
|
||||
}
|
||||
if (data.status === 'completed') {
|
||||
stockImportSuggestions = data.mapping_suggestions || [];
|
||||
renderStockImportSuggestions(stockImportSuggestions);
|
||||
showToast('Импорт stock_log завершен', 'success');
|
||||
await loadPricelists(1, 'warehouse');
|
||||
await loadStockMappings(stockMappingsPage);
|
||||
await loadStockIgnoreRules(stockIgnoreRulesPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1100,13 +1177,151 @@ async function importStockFile() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSuggestionReason(reason) {
|
||||
if (reason === 'conflict') return 'Конфликт';
|
||||
return 'Не найден LOT';
|
||||
}
|
||||
|
||||
function renderStockImportSuggestions(items) {
|
||||
const box = document.getElementById('stock-import-suggestions');
|
||||
const body = document.getElementById('stock-import-suggestions-body');
|
||||
if (!box || !body) return;
|
||||
if (!items || items.length === 0) {
|
||||
box.classList.add('hidden');
|
||||
body.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = items.map(item => `
|
||||
<tr>
|
||||
<td class="px-3 py-2 w-1/4 text-sm font-mono">
|
||||
<input data-role="suggestion-lot" data-partnumber="${escapeHtml(item.partnumber || '')}" list="stock-lot-options" autocomplete="off" type="text" class="px-2 py-1 border rounded w-full font-mono" placeholder="Выберите LOT">
|
||||
</td>
|
||||
<td class="px-3 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(item.description || '—')}</td>
|
||||
<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 || '')}">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="ignoreSuggestion(this)" class="text-amber-600 hover:text-amber-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="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
box.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function addSuggestionMapping(button) {
|
||||
const partnumber = (button?.dataset?.partnumber || '').trim();
|
||||
if (!partnumber) return;
|
||||
const description = decodeURIComponent(button?.querySelector('svg')?.dataset?.description || '');
|
||||
const input = document.querySelector(`input[data-role="suggestion-lot"][data-partnumber="${CSS.escape(partnumber)}"]`);
|
||||
const lotName = (input?.value || '').trim();
|
||||
try {
|
||||
const resp = await fetch('/api/admin/pricing/stock/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ partnumber: partnumber, lot_name: lotName, description: description })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
|
||||
stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
|
||||
renderStockImportSuggestions(stockImportSuggestions);
|
||||
await loadStockMappings(1);
|
||||
showToast('Сопоставление добавлено', 'success');
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function ignoreSuggestion(button) {
|
||||
const partnumber = (button?.dataset?.partnumber || '').trim();
|
||||
if (!partnumber) return;
|
||||
try {
|
||||
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 })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка добавления в игнорирование');
|
||||
stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
|
||||
renderStockImportSuggestions(stockImportSuggestions);
|
||||
await loadStockIgnoreRules(1);
|
||||
showToast('Добавлено в игнорирование', 'success');
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function addAllSuggestions() {
|
||||
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');
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
stockImportSuggestions = [];
|
||||
renderStockImportSuggestions(stockImportSuggestions);
|
||||
await loadStockIgnoreRules(1);
|
||||
showToast(`Добавлено в игнорирование: ${ok}`, 'success');
|
||||
}
|
||||
|
||||
async function loadStockMappings(page = 1) {
|
||||
stockMappingsPage = page;
|
||||
const body = document.getElementById('stock-mappings-body');
|
||||
const pagination = document.getElementById('stock-mappings-pagination');
|
||||
if (!body || !pagination) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20`);
|
||||
const query = encodeURIComponent(stockMappingsSearch);
|
||||
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20&search=${query}`);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
|
||||
const items = data.items || [];
|
||||
@@ -1115,12 +1330,30 @@ async function loadStockMappings(page = 1) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = items.map(item => `
|
||||
<tr class="cursor-pointer hover:bg-gray-50" onclick="selectStockMappingRow('${escapeHtml((item.partnumber || item.Partnumber || '')).replace(/'/g, "\\'")}')">
|
||||
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
|
||||
<tr>
|
||||
<td class="px-4 py-2 w-1/4 text-sm font-mono">
|
||||
<input data-role="lot" type="text" list="stock-lot-options" autocomplete="off" placeholder="Выберите LOT" value="${escapeHtml(item.lot_name || item.LotName || '')}" class="px-2 py-1 border rounded w-full font-mono">
|
||||
</td>
|
||||
<td class="px-4 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(item.description || item.Description || '—')}</td>
|
||||
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.lot_name || item.LotName || '—')}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" class="text-red-600 hover:text-red-800 text-sm">Удалить</button>
|
||||
<td class="px-4 py-2">
|
||||
<div class="flex justify-end items-center gap-3">
|
||||
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="saveInlineStockMapping(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>
|
||||
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); ignoreStockMapping(this.dataset.partnumber)" class="text-amber-600 hover:text-amber-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="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" class="text-red-600 hover:text-red-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="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12m4-12v12m4-12v12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -1143,19 +1376,23 @@ async function loadStockMappings(page = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectStockMappingRow(partnumber) {
|
||||
const normalized = (partnumber || '').trim().toLowerCase();
|
||||
const row = stockMappingsCache.find(item => ((item.partnumber || item.Partnumber || '').trim().toLowerCase() === normalized));
|
||||
if (!row) return;
|
||||
document.getElementById('mapping-partnumber').value = (row.partnumber || row.Partnumber || '').trim();
|
||||
document.getElementById('mapping-lotname').value = (row.lot_name || row.LotName || '').trim();
|
||||
function applyStockMappingsSearch() {
|
||||
stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
|
||||
loadStockMappings(1);
|
||||
}
|
||||
|
||||
async function saveStockMapping() {
|
||||
const partnumber = document.getElementById('mapping-partnumber').value.trim();
|
||||
const lotName = document.getElementById('mapping-lotname').value.trim();
|
||||
if (!partnumber || !lotName) {
|
||||
showToast('Заполните partnumber и lot_name', 'error');
|
||||
function onStockMappingsSearchInput() {
|
||||
clearTimeout(stockMappingsSearchTimer);
|
||||
stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250);
|
||||
}
|
||||
|
||||
async function saveInlineStockMapping(button) {
|
||||
const partnumber = (button?.dataset?.partnumber || '').trim();
|
||||
if (!partnumber) return;
|
||||
const row = button.closest('tr');
|
||||
const lotName = (row?.querySelector('input[data-role="lot"]')?.value || '').trim();
|
||||
if (!lotName) {
|
||||
showToast('LOT не может быть пустым', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1166,10 +1403,103 @@ async function saveStockMapping() {
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
|
||||
document.getElementById('mapping-partnumber').value = '';
|
||||
document.getElementById('mapping-lotname').value = '';
|
||||
showToast('Сопоставление сохранено', 'success');
|
||||
await loadStockMappings(1);
|
||||
await loadStockMappings(stockMappingsPage);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStockLotOptions() {
|
||||
const datalist = document.getElementById('stock-lot-options');
|
||||
if (!datalist) return;
|
||||
try {
|
||||
const resp = await fetch('/api/admin/pricing/lots?per_page=5000');
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки LOT');
|
||||
const items = data.items || [];
|
||||
datalist.innerHTML = items.map(lot => `<option value="${escapeHtml(lot)}"></option>`).join('');
|
||||
} catch (_) {
|
||||
datalist.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStockIgnoreRules(page = 1) {
|
||||
stockIgnoreRulesPage = page;
|
||||
const body = document.getElementById('stock-ignore-rules-body');
|
||||
const pagination = document.getElementById('stock-ignore-rules-pagination');
|
||||
if (!body || !pagination) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/pricing/stock/ignore-rules?page=${page}&per_page=20`);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет правил</td></tr>';
|
||||
} else {
|
||||
const matchLabel = { exact: 'Равно', prefix: 'Начинается с', suffix: 'Заканчивается на' };
|
||||
body.innerHTML = items.map(item => `
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-sm">${escapeHtml(item.target)}</td>
|
||||
<td class="px-4 py-2 text-sm">${escapeHtml(matchLabel[item.match_type] || item.match_type)}</td>
|
||||
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.pattern)}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button onclick="deleteStockIgnoreRule(${item.id})" class="text-red-600 hover:text-red-800 text-sm">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
const totalPages = Math.ceil((data.total || 0) / (data.per_page || 20));
|
||||
if (totalPages <= 1) {
|
||||
pagination.innerHTML = '';
|
||||
} else {
|
||||
let html = '';
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const cls = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
|
||||
html += `<button onclick="loadStockIgnoreRules(${i})" class="px-3 py-1 rounded border ${cls}">${i}</button>`;
|
||||
}
|
||||
pagination.innerHTML = html;
|
||||
}
|
||||
} catch (e) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
|
||||
pagination.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStockIgnoreRule() {
|
||||
const target = document.getElementById('ignore-target').value;
|
||||
const matchType = document.getElementById('ignore-match-type').value;
|
||||
const pattern = document.getElementById('ignore-pattern').value.trim();
|
||||
if (!pattern) {
|
||||
showToast('Заполните шаблон', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target: target, match_type: matchType, pattern: pattern })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
|
||||
document.getElementById('ignore-pattern').value = '';
|
||||
showToast('Правило добавлено', 'success');
|
||||
await loadStockIgnoreRules(1);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStockIgnoreRule(id) {
|
||||
if (!confirm('Удалить правило игнорирования?')) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/pricing/stock/ignore-rules/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
|
||||
showToast('Правило удалено', 'success');
|
||||
await loadStockIgnoreRules(stockIgnoreRulesPage);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
@@ -1190,6 +1520,33 @@ async function deleteStockMapping(partnumber) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ignoreStockMapping(partnumber) {
|
||||
const pn = (partnumber || '').trim();
|
||||
if (!pn) return;
|
||||
if (!confirm('Добавить партномер в игнорирование и удалить из сопоставлений?')) return;
|
||||
try {
|
||||
const addResp = await fetch('/api/admin/pricing/stock/ignore-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: pn })
|
||||
});
|
||||
const addData = await addResp.json();
|
||||
if (!addResp.ok) throw new Error(addData.error || 'Ошибка добавления в игнорирование');
|
||||
|
||||
const delResp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(pn)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const delData = await delResp.json();
|
||||
if (!delResp.ok) throw new Error(delData.error || 'Ошибка удаления сопоставления');
|
||||
|
||||
showToast('Партномер добавлен в игнорирование', 'success');
|
||||
await loadStockMappings(stockMappingsPage);
|
||||
await loadStockIgnoreRules(1);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await checkPricelistWritePermission();
|
||||
// Check URL params for initial tab
|
||||
@@ -1198,6 +1555,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (initialTab === 'pricelists') initialTab = 'estimate';
|
||||
if (initialTab === 'components') initialTab = 'component-settings';
|
||||
await loadTab(initialTab);
|
||||
const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
|
||||
if (stockMappingsSearchEl) {
|
||||
stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput);
|
||||
}
|
||||
|
||||
// Add event listeners for preview updates
|
||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||
|
||||
Reference in New Issue
Block a user