Replace SSE with smart polling (30s idle / 3s when running). Unify sync panel into single two-row card with live progress text. Add unified filter bar (period dropdown, status pills, search) with period-total counts. Add Client/Cont tooltip for different shipping/billing persons. Add SKU mappings pct_total badges + complete/incomplete filter + 409 duplicate check. Add missing SKUs search + rescan progress UX. Migrate SQLite orders schema (shipping_name, billing_name, payment_method, delivery_method). Fix JSON_OUTPUT_DIR path for server running from project root. Fix pagination controls showing top+bottom with per-page selector (25/50/100/250). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
409 lines
17 KiB
HTML
409 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
|
{% block nav_missing %}active{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h4 class="mb-0">SKU-uri Lipsa</h4>
|
|
<div>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
|
<i class="bi bi-download"></i> Export CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unified filter bar -->
|
|
<div class="filter-bar" id="skusFilterBar">
|
|
<button class="filter-pill active" data-sku-status="unresolved">
|
|
Nerezolvate <span class="filter-count" id="cntUnres">0</span>
|
|
</button>
|
|
<button class="filter-pill" data-sku-status="resolved">
|
|
Rezolvate <span class="filter-count" id="cntRes">0</span>
|
|
</button>
|
|
<button class="filter-pill" data-sku-status="all">
|
|
Toate <span class="filter-count" id="cntAllSkus">0</span>
|
|
</button>
|
|
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
|
|
<button id="rescanBtn" class="btn btn-secondary btn-compact" style="margin-left:0.5rem;">↻ Re-scan</button>
|
|
<span id="rescanProgress" style="display:none;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#1d4ed8;">
|
|
<span class="sync-live-dot"></span>
|
|
<span id="rescanProgressText">Scanare...</span>
|
|
</span>
|
|
</div>
|
|
<!-- Result banner -->
|
|
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
|
|
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>SKU</th>
|
|
<th>Produs</th>
|
|
<th>Nr. Comenzi</th>
|
|
<th>Client</th>
|
|
<th>First Seen</th>
|
|
<th>Status</th>
|
|
<th>Actiune</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="missingBody">
|
|
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer">
|
|
<small class="text-muted" id="missingInfo"></small>
|
|
</div>
|
|
</div>
|
|
|
|
<nav id="paginationNav" class="mt-3">
|
|
<ul class="pagination justify-content-center" id="paginationControls"></ul>
|
|
</nav>
|
|
|
|
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
|
|
<div class="modal fade" id="mapModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-2">
|
|
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
|
|
</div>
|
|
<div id="mapCodmatLines">
|
|
<!-- Dynamic CODMAT lines -->
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
|
|
<i class="bi bi-plus"></i> Adauga CODMAT
|
|
</button>
|
|
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
let currentMapSku = '';
|
|
let mapAcTimeout = null;
|
|
let currentPage = 1;
|
|
let skuStatusFilter = 'unresolved';
|
|
const perPage = 20;
|
|
|
|
// ── Filter pills ──────────────────────────────────
|
|
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
skuStatusFilter = this.dataset.skuStatus;
|
|
currentPage = 1;
|
|
loadMissingSkus();
|
|
});
|
|
});
|
|
|
|
// ── Search with debounce ─────────────────────────
|
|
let skuSearchTimer = null;
|
|
document.getElementById('skuSearch')?.addEventListener('input', function() {
|
|
clearTimeout(skuSearchTimer);
|
|
skuSearchTimer = setTimeout(() => { currentPage = 1; loadMissingSkus(); }, 300);
|
|
});
|
|
|
|
// ── Rescan ────────────────────────────────────────
|
|
document.getElementById('rescanBtn')?.addEventListener('click', async function() {
|
|
this.disabled = true;
|
|
const prog = document.getElementById('rescanProgress');
|
|
const result = document.getElementById('rescanResult');
|
|
const progText = document.getElementById('rescanProgressText');
|
|
if (prog) { prog.style.display = 'flex'; }
|
|
if (result) result.style.display = 'none';
|
|
try {
|
|
const data = await fetch('/api/validate/scan', { method: 'POST' }).then(r => r.json());
|
|
if (progText) progText.textContent = 'Gata.';
|
|
if (result) {
|
|
result.innerHTML = `✓ ${data.total_skus_scanned || 0} scanate | ${data.new_missing || 0} noi lipsa | ${data.auto_resolved || 0} rezolvate`;
|
|
result.style.display = 'block';
|
|
}
|
|
loadMissingSkus();
|
|
} catch(e) {
|
|
if (progText) progText.textContent = 'Eroare.';
|
|
} finally {
|
|
this.disabled = false;
|
|
setTimeout(() => { if (prog) prog.style.display = 'none'; }, 2500);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadMissingSkus();
|
|
});
|
|
|
|
function resolvedParamFor(statusFilter) {
|
|
if (statusFilter === 'resolved') return 1;
|
|
if (statusFilter === 'all') return -1;
|
|
return 0; // unresolved (default)
|
|
}
|
|
|
|
function loadMissingSkus(page) {
|
|
currentPage = page || currentPage;
|
|
const params = new URLSearchParams();
|
|
const resolvedVal = resolvedParamFor(skuStatusFilter);
|
|
params.set('resolved', resolvedVal);
|
|
params.set('page', currentPage);
|
|
params.set('per_page', perPage);
|
|
const search = document.getElementById('skuSearch')?.value?.trim();
|
|
if (search) params.set('search', search);
|
|
|
|
fetch('/api/validate/missing-skus?' + params.toString())
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const c = data.counts || {};
|
|
const el = id => document.getElementById(id);
|
|
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
|
|
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
|
|
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0;
|
|
renderMissingSkusTable(data.skus || data.missing_skus || [], data);
|
|
renderPagination(data);
|
|
})
|
|
.catch(err => {
|
|
document.getElementById('missingBody').innerHTML =
|
|
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
|
|
});
|
|
}
|
|
|
|
// Keep backward compat alias
|
|
function loadMissing(page) { loadMissingSkus(page); }
|
|
|
|
function renderMissingSkusTable(skus, data) {
|
|
const tbody = document.getElementById('missingBody');
|
|
if (data) {
|
|
document.getElementById('missingInfo').textContent =
|
|
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
|
|
}
|
|
|
|
if (!skus || skus.length === 0) {
|
|
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
|
|
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
|
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = skus.map(s => {
|
|
const statusBadge = s.resolved
|
|
? '<span class="badge bg-success">Rezolvat</span>'
|
|
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
|
|
|
|
let firstCustomer = '-';
|
|
try {
|
|
const customers = JSON.parse(s.customers || '[]');
|
|
if (customers.length > 0) firstCustomer = customers[0];
|
|
} catch (e) { /* ignore */ }
|
|
|
|
const orderCount = s.order_count != null ? s.order_count : '-';
|
|
|
|
return `<tr class="${s.resolved ? 'table-light' : ''}">
|
|
<td><code>${esc(s.sku)}</code></td>
|
|
<td>${esc(s.product_name || '-')}</td>
|
|
<td>${esc(orderCount)}</td>
|
|
<td><small>${esc(firstCustomer)}</small></td>
|
|
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
|
|
<td>${statusBadge}</td>
|
|
<td>
|
|
${!s.resolved
|
|
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
|
|
<i class="bi bi-link-45deg"></i>
|
|
</a>`
|
|
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderPagination(data) {
|
|
const ul = document.getElementById('paginationControls');
|
|
const total = data.pages || 1;
|
|
const page = data.page || 1;
|
|
if (total <= 1) { ul.innerHTML = ''; return; }
|
|
|
|
let html = '';
|
|
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
|
|
|
|
const range = 2;
|
|
for (let i = 1; i <= total; i++) {
|
|
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
|
|
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
|
<a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
|
|
} else if (i === page - range - 1 || i === page + range + 1) {
|
|
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
|
|
}
|
|
}
|
|
|
|
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
|
|
ul.innerHTML = html;
|
|
}
|
|
|
|
// ── Multi-CODMAT Map Modal ───────────────────────
|
|
|
|
function openMapModal(sku, productName) {
|
|
currentMapSku = sku;
|
|
document.getElementById('mapSku').textContent = sku;
|
|
document.getElementById('mapProductName').textContent = productName || '-';
|
|
document.getElementById('mapPctWarning').style.display = 'none';
|
|
|
|
const container = document.getElementById('mapCodmatLines');
|
|
container.innerHTML = '';
|
|
addMapCodmatLine();
|
|
|
|
// Pre-search with product name
|
|
if (productName) {
|
|
setTimeout(() => {
|
|
const input = container.querySelector('.mc-codmat');
|
|
if (input) {
|
|
input.value = productName;
|
|
mcAutocomplete(input,
|
|
container.querySelector('.mc-ac-dropdown'),
|
|
container.querySelector('.mc-selected'));
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
|
}
|
|
|
|
function addMapCodmatLine() {
|
|
const container = document.getElementById('mapCodmatLines');
|
|
const idx = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'border rounded p-2 mb-2 mc-line';
|
|
div.innerHTML = `
|
|
<div class="mb-2 position-relative">
|
|
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
|
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
|
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
|
|
<small class="text-muted mc-selected"></small>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-5">
|
|
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
|
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001">
|
|
</div>
|
|
<div class="col-5">
|
|
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
|
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100">
|
|
</div>
|
|
<div class="col-2 d-flex align-items-end">
|
|
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
|
|
const input = div.querySelector('.mc-codmat');
|
|
const dropdown = div.querySelector('.mc-ac-dropdown');
|
|
const selected = div.querySelector('.mc-selected');
|
|
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(mapAcTimeout);
|
|
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
});
|
|
}
|
|
|
|
async function mcAutocomplete(input, dropdown, selectedEl) {
|
|
const q = input.value;
|
|
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
|
try {
|
|
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
|
const data = await res.json();
|
|
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
|
|
|
dropdown.innerHTML = data.results.map(r =>
|
|
`<div class="autocomplete-item" onmousedown="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
|
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
|
</div>`
|
|
).join('');
|
|
dropdown.classList.remove('d-none');
|
|
} catch { dropdown.classList.add('d-none'); }
|
|
}
|
|
|
|
function mcSelectArticle(el, codmat, label) {
|
|
const line = el.closest('.mc-line');
|
|
line.querySelector('.mc-codmat').value = codmat;
|
|
line.querySelector('.mc-selected').textContent = label;
|
|
line.querySelector('.mc-ac-dropdown').classList.add('d-none');
|
|
}
|
|
|
|
async function saveQuickMap() {
|
|
const lines = document.querySelectorAll('.mc-line');
|
|
const mappings = [];
|
|
|
|
for (const line of lines) {
|
|
const codmat = line.querySelector('.mc-codmat').value.trim();
|
|
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
|
|
const procent = parseFloat(line.querySelector('.mc-procent').value) || 100;
|
|
if (!codmat) continue;
|
|
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
|
|
}
|
|
|
|
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
|
|
|
|
if (mappings.length > 1) {
|
|
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
|
|
if (Math.abs(totalPct - 100) > 0.01) {
|
|
document.getElementById('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
|
document.getElementById('mapPctWarning').style.display = '';
|
|
return;
|
|
}
|
|
}
|
|
document.getElementById('mapPctWarning').style.display = 'none';
|
|
|
|
try {
|
|
let res;
|
|
if (mappings.length === 1) {
|
|
res = await fetch('/api/mappings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sku: currentMapSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
|
|
});
|
|
} else {
|
|
res = await fetch('/api/mappings/batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sku: currentMapSku, mappings })
|
|
});
|
|
}
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
|
loadMissingSkus(currentPage);
|
|
} else {
|
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function exportMissingCsv() {
|
|
window.location.href = '/api/validate/missing-skus-csv';
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
</script>
|
|
{% endblock %}
|