feat(dashboard): redesign UI with smart polling, unified sync card, filter bar
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>
This commit is contained in:
@@ -9,24 +9,29 @@
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
||||
<i class="bi bi-download"></i> Export CSV
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
|
||||
<i class="bi bi-search"></i> Re-Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolved toggle (R10) -->
|
||||
<div class="btn-group mb-3" role="group">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btnUnresolved" onclick="setResolvedFilter(0)">
|
||||
Nerezolvate
|
||||
<!-- 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 type="button" class="btn btn-sm btn-outline-success" id="btnResolved" onclick="setResolvedFilter(1)">
|
||||
Rezolvate
|
||||
<button class="filter-pill" data-sku-status="resolved">
|
||||
Rezolvate <span class="filter-count" id="cntRes">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnAll" onclick="setResolvedFilter(-1)">
|
||||
Toate
|
||||
<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">
|
||||
@@ -92,77 +97,134 @@
|
||||
let currentMapSku = '';
|
||||
let mapAcTimeout = null;
|
||||
let currentPage = 1;
|
||||
let currentResolved = 0;
|
||||
let skuStatusFilter = 'unresolved';
|
||||
const perPage = 20;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadMissing(1);
|
||||
// ── 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();
|
||||
});
|
||||
});
|
||||
|
||||
function setResolvedFilter(val) {
|
||||
currentResolved = val;
|
||||
currentPage = 1;
|
||||
// Update button styles
|
||||
document.getElementById('btnUnresolved').className = 'btn btn-sm ' + (val === 0 ? 'btn-primary' : 'btn-outline-primary');
|
||||
document.getElementById('btnResolved').className = 'btn btn-sm ' + (val === 1 ? 'btn-success' : 'btn-outline-success');
|
||||
document.getElementById('btnAll').className = 'btn btn-sm ' + (val === -1 ? 'btn-secondary' : 'btn-outline-secondary');
|
||||
loadMissing(1);
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
async function loadMissing(page) {
|
||||
currentPage = page || 1;
|
||||
try {
|
||||
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}&resolved=${currentResolved}`);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingBody');
|
||||
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}`;
|
||||
|
||||
const skus = data.missing_skus || [];
|
||||
if (skus.length === 0) {
|
||||
const msg = currentResolved === 0 ? 'Toate SKU-urile sunt mapate!' :
|
||||
currentResolved === 1 ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
|
||||
renderPagination(data);
|
||||
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('');
|
||||
|
||||
renderPagination(data);
|
||||
} catch (err) {
|
||||
document.getElementById('missingBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -173,20 +235,20 @@ function renderPagination(data) {
|
||||
|
||||
let html = '';
|
||||
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a></li>`;
|
||||
<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="loadMissing(${i}); return false;">${i}</a></li>`;
|
||||
<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="loadMissing(${page + 1}); return false;">Urmator</a></li>`;
|
||||
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
|
||||
ul.innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -325,7 +387,7 @@ async function saveQuickMap() {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||
loadMissing(currentPage);
|
||||
loadMissingSkus(currentPage);
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
@@ -334,15 +396,6 @@ async function saveQuickMap() {
|
||||
}
|
||||
}
|
||||
|
||||
async function scanForMissing() {
|
||||
try {
|
||||
await fetch('/api/validate/scan', { method: 'POST' });
|
||||
loadMissing(1);
|
||||
} catch (err) {
|
||||
alert('Eroare scan: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function exportMissingCsv() {
|
||||
window.location.href = '/api/validate/missing-skus-csv';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user