Files
PriceForge/web/templates/vendor_mappings.html

380 lines
17 KiB
HTML

{{define "title"}}Vendor mappings - PriceForge{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold">Глобальные сопоставления Vendor Partnumbers</h1>
<div class="bg-white rounded-lg shadow p-4 space-y-4">
<div class="flex gap-3 items-center">
<input id="vm-search" type="text" placeholder="Поиск по vendor / partnumber / LOT / описанию" class="flex-1 px-3 py-2 border rounded">
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input id="vm-unmapped" type="checkbox" class="rounded border-gray-300">
<span>Партномера без сопоставления</span>
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input id="vm-ignored" type="checkbox" class="rounded border-gray-300">
<span>Игнорируемые</span>
</label>
<button onclick="openVendorMappingModal()" class="px-3 py-2 bg-orange-600 text-white rounded hover:bg-orange-700">Создать</button>
<button onclick="loadVendorMappings(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">Vendor</th>
<th class="px-4 py-2 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">LOT</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Ignored</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Sources</th>
</tr>
</thead>
<tbody id="vm-body" class="bg-white divide-y divide-gray-200">
<tr><td colspan="7" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div id="vm-pagination" class="flex justify-center gap-2"></div>
</div>
</div>
<div id="vm-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-3xl w-full mx-4 space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">Сопоставление</h2>
<button onclick="closeVendorMappingModal()" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-600 mb-1">Vendor</label>
<input id="vm-modal-vendor" type="text" class="w-full px-3 py-2 border rounded">
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">Partnumber</label>
<input id="vm-modal-partnumber" type="text" class="w-full px-3 py-2 border rounded font-mono">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-600 mb-1">LOT</label>
<input id="vm-modal-lot" type="text" list="vm-lot-options" autocomplete="off" class="w-full px-3 py-2 border rounded font-mono">
<datalist id="vm-lot-options"></datalist>
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">Описание</label>
<textarea id="vm-modal-description" rows="4" class="w-full px-3 py-2 border rounded resize-y"></textarea>
</div>
<div id="vm-bundle-box" class="hidden border rounded p-3 bg-gray-50">
<h3 class="text-sm font-semibold mb-2">Состав bundle (LOT + qty)</h3>
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left py-1">LOT</th>
<th class="text-right py-1">Qty</th>
</tr>
</thead>
<tbody id="vm-bundle-items"></tbody>
</table>
</div>
<div id="vm-modal-error" class="hidden p-2 rounded bg-red-50 text-red-700 text-sm"></div>
<div class="flex justify-between">
<div class="space-x-2">
<button id="vm-btn-ignore-toggle" onclick="toggleIgnoreVendorMapping()" class="px-3 py-2 border rounded hover:bg-gray-50">Игнорировать</button>
<button id="vm-btn-delete" onclick="deleteVendorMapping()" class="px-3 py-2 border border-red-300 text-red-700 rounded hover:bg-red-50">Удалить</button>
</div>
<div class="space-x-2">
<button onclick="closeVendorMappingModal()" class="px-3 py-2 border rounded hover:bg-gray-50">Отмена</button>
<button onclick="saveVendorMapping()" class="px-3 py-2 bg-orange-600 text-white rounded hover:bg-orange-700">Сохранить</button>
</div>
</div>
</div>
</div>
<script>
let vmPage = 1;
let vmSearchTimer = null;
let vmCurrent = { vendor: '', partnumber: '', ignored: false };
let vmLotSearchTimer = null;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function buildCompactPagination(current, total, onClickFn) {
if (total <= 1) return '';
const parts = [];
const addBtn = (label, page, active = false, disabled = false) => {
const cls = active
? 'bg-orange-600 text-white border-orange-600'
: 'bg-white border-gray-300 hover:bg-gray-50';
const dis = disabled ? 'opacity-50 cursor-not-allowed' : '';
if (disabled) {
parts.push(`<span class="px-3 py-1 rounded border ${cls} ${dis}">${label}</span>`);
return;
}
parts.push(`<button onclick="${onClickFn}(${page})" class="px-3 py-1 rounded border ${cls} ${dis}">${label}</button>`);
};
const addEllipsis = () => parts.push('<span class="px-2 py-1 text-gray-500">…</span>');
addBtn('←', Math.max(1, current - 1), false, current <= 1);
const windowSize = 2;
const start = Math.max(1, current - windowSize);
const end = Math.min(total, current + windowSize);
if (start > 1) {
addBtn('1', 1, current === 1);
}
if (start > 2) addEllipsis();
for (let i = start; i <= end; i++) {
addBtn(String(i), i, i === current);
}
if (end < total - 1) addEllipsis();
if (end < total) {
addBtn(String(total), total, current === total);
}
addBtn('→', Math.min(total, current + 1), false, current >= total);
return parts.join('');
}
function renderVmError(msg) {
const box = document.getElementById('vm-modal-error');
if (!msg) {
box.classList.add('hidden');
box.textContent = '';
return;
}
box.classList.remove('hidden');
box.textContent = msg;
}
function renderIgnoreToggleState() {
const btn = document.getElementById('vm-btn-ignore-toggle');
if (!btn) return;
if (vmCurrent.ignored) {
btn.textContent = 'Убрать игнор';
btn.className = 'px-3 py-2 border border-amber-300 text-amber-800 rounded hover:bg-amber-50';
} else {
btn.textContent = 'Игнорировать';
btn.className = 'px-3 py-2 border rounded hover:bg-gray-50';
}
}
async function loadLotAutocompleteOptions(query) {
const q = (query || '').trim();
const dl = document.getElementById('vm-lot-options');
if (!dl) return;
if (q.length < 3) {
dl.innerHTML = '';
return;
}
try {
const resp = await fetch(`/api/admin/pricing/lots?per_page=30&search=${encodeURIComponent(q)}`);
const data = await resp.json();
if (!resp.ok) return;
const items = Array.isArray(data.items) ? data.items : []; // API returns []string
dl.innerHTML = items
.filter(x => typeof x === 'string' && x.trim() !== '')
.map(x => `<option value="${escapeHtml(x)}"></option>`)
.join('');
} catch (_) {
// ignore autocomplete load errors
}
}
async function loadVendorMappings(page = 1) {
vmPage = page;
const body = document.getElementById('vm-body');
const pagination = document.getElementById('vm-pagination');
body.innerHTML = '<tr><td colspan="7" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>';
const search = encodeURIComponent(document.getElementById('vm-search').value.trim());
const unmapped = document.getElementById('vm-unmapped').checked;
const ignored = document.getElementById('vm-ignored').checked;
try {
const resp = await fetch(`/api/admin/pricing/vendor-mappings?page=${page}&per_page=20&search=${search}&unmapped_only=${unmapped}&ignored_only=${ignored}`);
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="7" class="px-4 py-3 text-sm text-gray-500">Нет данных</td></tr>';
} else {
body.innerHTML = items.map(item => {
const status = item.unmapped ? 'UNMAPPED' : 'MAPPED';
const ignoredTxt = item.ignored ? 'YES' : 'NO';
const sources = (item.sources || []).join(', ');
return `<tr class="hover:bg-gray-50 cursor-pointer" onclick="openVendorMappingModal('${encodeURIComponent(item.vendor || '')}','${encodeURIComponent(item.partnumber || '')}')">
<td class="px-4 py-2 text-sm">${escapeHtml(item.vendor || '—')}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber || '')}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.lot_name || '—')}</td>
<td class="px-4 py-2 text-sm">${escapeHtml(item.type || 'single')}</td>
<td class="px-4 py-2 text-sm">${status}</td>
<td class="px-4 py-2 text-sm">${ignoredTxt}</td>
<td class="px-4 py-2 text-sm">${escapeHtml(sources)}</td>
</tr>`;
}).join('');
}
const total = Number(data.total || 0);
const totalPages = Math.max(1, Math.ceil(total / 20));
pagination.innerHTML = buildCompactPagination(vmPage, totalPages, 'loadVendorMappings');
} catch (e) {
body.innerHTML = `<tr><td colspan="7" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
pagination.innerHTML = '';
}
}
async function openVendorMappingModal(vendor = '', partnumber = '') {
renderVmError('');
vmCurrent = { vendor: decodeURIComponent(vendor || ''), partnumber: decodeURIComponent(partnumber || ''), ignored: false };
document.getElementById('vm-modal-vendor').value = vmCurrent.vendor;
document.getElementById('vm-modal-partnumber').value = vmCurrent.partnumber;
document.getElementById('vm-modal-lot').value = '';
document.getElementById('vm-modal-description').value = '';
document.getElementById('vm-bundle-box').classList.add('hidden');
document.getElementById('vm-bundle-items').innerHTML = '';
if (vmCurrent.partnumber) {
try {
const q = `vendor=${encodeURIComponent(vmCurrent.vendor)}&partnumber=${encodeURIComponent(vmCurrent.partnumber)}`;
const resp = await fetch(`/api/admin/pricing/vendor-mappings/detail?${q}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
document.getElementById('vm-modal-lot').value = data.lot_name || '';
document.getElementById('vm-modal-description').value = data.description || '';
vmCurrent.ignored = !!data.ignored;
renderIgnoreToggleState();
if ((data.type || '') === 'bundle' && Array.isArray(data.items) && data.items.length > 0) {
document.getElementById('vm-bundle-box').classList.remove('hidden');
document.getElementById('vm-bundle-items').innerHTML = data.items.map(it => (
`<tr><td class="py-1 font-mono">${escapeHtml(it.lot_name || '')}</td><td class="py-1 text-right">${Number(it.qty || 0).toLocaleString('ru-RU')}</td></tr>`
)).join('');
}
} catch (e) {
renderVmError(e.message || 'Ошибка загрузки');
}
}
renderIgnoreToggleState();
document.getElementById('vm-modal').classList.remove('hidden');
document.getElementById('vm-modal').classList.add('flex');
}
function closeVendorMappingModal() {
document.getElementById('vm-modal').classList.add('hidden');
document.getElementById('vm-modal').classList.remove('flex');
}
async function saveVendorMapping() {
renderVmError('');
const payload = {
vendor: document.getElementById('vm-modal-vendor').value.trim(),
partnumber: document.getElementById('vm-modal-partnumber').value.trim(),
lot_name: document.getElementById('vm-modal-lot').value.trim(),
description: document.getElementById('vm-modal-description').value.trim(),
};
try {
const resp = await fetch('/api/admin/pricing/vendor-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
closeVendorMappingModal();
await loadVendorMappings(vmPage);
} catch (e) {
renderVmError(e.message || 'Ошибка сохранения');
}
}
async function ignoreVendorMapping(ignore) {
renderVmError('');
const payload = {
vendor: document.getElementById('vm-modal-vendor').value.trim(),
partnumber: document.getElementById('vm-modal-partnumber').value.trim(),
};
try {
const resp = await fetch(ignore ? '/api/admin/pricing/vendor-mappings/ignore' : '/api/admin/pricing/vendor-mappings/unignore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка обновления ignore');
vmCurrent.ignored = ignore;
renderIgnoreToggleState();
await loadVendorMappings(vmPage);
} catch (e) {
renderVmError(e.message || 'Ошибка обновления ignore');
}
}
async function toggleIgnoreVendorMapping() {
await ignoreVendorMapping(!vmCurrent.ignored);
}
async function deleteVendorMapping() {
const partnumber = document.getElementById('vm-modal-partnumber').value.trim();
if (!partnumber) return;
if (!confirm('Удалить сопоставление?')) return;
renderVmError('');
const payload = {
vendor: document.getElementById('vm-modal-vendor').value.trim(),
partnumber,
};
try {
const resp = await fetch('/api/admin/pricing/vendor-mappings', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
closeVendorMappingModal();
await loadVendorMappings(vmPage);
} catch (e) {
renderVmError(e.message || 'Ошибка удаления');
}
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('vm-search').addEventListener('input', () => {
clearTimeout(vmSearchTimer);
vmSearchTimer = setTimeout(() => loadVendorMappings(1), 250);
});
document.getElementById('vm-unmapped').addEventListener('change', () => loadVendorMappings(1));
document.getElementById('vm-ignored').addEventListener('change', () => loadVendorMappings(1));
const lotInput = document.getElementById('vm-modal-lot');
if (lotInput) {
lotInput.addEventListener('input', () => {
clearTimeout(vmLotSearchTimer);
vmLotSearchTimer = setTimeout(() => loadLotAutocompleteOptions(lotInput.value), 220);
});
lotInput.addEventListener('focus', () => {
if ((lotInput.value || '').trim().length >= 3) {
loadLotAutocompleteOptions(lotInput.value);
}
});
}
loadVendorMappings(1);
});
</script>
{{end}}
{{template "base" .}}