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:
2026-03-13 17:55:36 +02:00
parent 82196b9dc0
commit 5f8b9b6003
14 changed files with 1235 additions and 648 deletions

View File

@@ -5,102 +5,86 @@
{% block content %}
<h4 class="mb-4">Panou de Comanda</h4>
<!-- Sync Control -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Control</span>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
<i class="bi bi-play-fill"></i> Start Sync
</button>
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
<i class="bi bi-stop-fill"></i> Stop
</button>
</div>
<div class="col-auto">
<div class="form-check form-switch d-inline-block me-2">
<input class="form-check-input" type="checkbox" id="schedulerToggle" onchange="toggleScheduler()">
<label class="form-check-label" for="schedulerToggle">Scheduler</label>
</div>
<select class="form-select form-select-sm d-inline-block" style="width:auto" id="schedulerInterval" onchange="updateSchedulerInterval()">
<option value="1">1 min</option>
<option value="5" selected>5 min</option>
<option value="10">10 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
</div>
<div class="col">
<small class="text-muted" id="syncProgressText"></small>
</div>
</div>
<div class="mt-2 d-none" id="syncStartedBanner">
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block">
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small>
</div>
</div>
</div>
</div>
<!-- Last Sync Summary Card -->
<div class="card mb-4" id="lastSyncCard">
<div class="card-header d-flex justify-content-between align-items-center cursor-pointer" data-bs-toggle="collapse" data-bs-target="#lastSyncBody">
<span>Ultimul Sync</span>
<i class="bi bi-chevron-down"></i>
</div>
<div class="collapse show" id="lastSyncBody">
<div class="card-body">
<div class="row text-center" id="lastSyncRow">
<div class="col last-sync-col"><small class="text-muted">Data</small><br><strong id="lastSyncDate">-</strong></div>
<div class="col last-sync-col"><small class="text-muted">Status</small><br><span id="lastSyncStatus">-</span></div>
<div class="col last-sync-col"><small class="text-muted">Importate</small><br><strong class="text-success" id="lastSyncImported">0</strong></div>
<div class="col last-sync-col"><small class="text-muted">Omise</small><br><strong class="text-warning" id="lastSyncSkipped">0</strong></div>
<div class="col last-sync-col"><small class="text-muted">Erori</small><br><strong class="text-danger" id="lastSyncErrors">0</strong></div>
<div class="col"><small class="text-muted">Durata</small><br><strong id="lastSyncDuration">-</strong></div>
</div>
</div>
<!-- Sync Card (unified two-row panel) -->
<div class="sync-card">
<!-- TOP ROW: Status + Controls -->
<div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" style="font-size:0.8125rem;color:#374151;">Inactiv</span>
<div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;">
Auto:
<input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()">
</label>
<select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
<option value="5">5 min</option>
<option value="10" selected>10 min</option>
<option value="30">30 min</option>
</select>
<button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">&#9654; Start Sync</button>
</div>
</div>
<div class="sync-card-divider"></div>
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
<span id="lastSyncDate" style="font-weight:500;">&#8212;</span>
<span id="lastSyncDuration" style="color:#9ca3af;">&#8212;</span>
<span id="lastSyncCounts">&#8212;</span>
<span id="lastSyncStatus">&#8212;</span>
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">&#8599; jurnal</span>
</div>
<!-- LIVE PROGRESS (shown only when sync is running) -->
<div class="sync-card-progress" id="syncProgressArea" style="display:none;">
<span class="sync-live-dot"></span>
<span id="syncProgressText">Se proceseaza...</span>
</div>
</div>
<!-- Orders Table -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<span>Comenzi</span>
<div class="btn-group btn-group-sm" role="group" id="dashPeriodBtns">
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="3" onclick="dashSetPeriod(3)">3 zile</button>
<button type="button" class="btn btn-sm btn-secondary" data-days="7" onclick="dashSetPeriod(7)">7 zile</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="30" onclick="dashSetPeriod(30)">30 zile</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="0" onclick="dashSetPeriod(0)">Toate</button>
</div>
</div>
<div class="input-group input-group-sm" style="width:250px">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="dashSearchInput" placeholder="Cauta..." oninput="debounceDashSearch()">
<div class="card-header">
<span>Comenzi</span>
</div>
<div class="card-body py-2 px-3">
<div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown -->
<select id="periodSelect" class="select-compact">
<option value="3">3 zile</option>
<option value="7" selected>7 zile</option>
<option value="30">30 zile</option>
<option value="90">3 luni</option>
<option value="0">Toate</option>
<option value="custom">Perioada personalizata...</option>
</select>
<!-- Custom date range (hidden until 'custom' selected) -->
<div class="period-custom-range" id="customRangeInputs">
<input type="date" id="periodStart" class="select-compact">
<span>&#8212;</span>
<input type="date" id="periodEnd" class="select-compact">
</div>
<!-- Status pills -->
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" id="cntAll">0</span></button>
<button class="filter-pill" data-status="IMPORTED">Imp. <span class="filter-count" id="cntImp">0</span></button>
<button class="filter-pill" data-status="SKIPPED">Omise <span class="filter-count" id="cntSkip">0</span></button>
<button class="filter-pill" data-status="ERROR">Erori <span class="filter-count" id="cntErr">0</span></button>
<button class="filter-pill" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">0</span></button>
<!-- Search (integrated, end of row) -->
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
</div>
</div>
<div class="card-body py-2">
<div class="btn-group" role="group" id="dashFilterBtns">
<button type="button" class="btn btn-sm btn-primary" onclick="dashFilterOrders('all')">
Toate <span class="badge bg-light text-dark ms-1" id="dashCountAll">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="dashFilterOrders('IMPORTED')">
Importate <span class="badge bg-light text-dark ms-1" id="dashCountImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="dashFilterOrders('SKIPPED')">
Omise <span class="badge bg-light text-dark ms-1" id="dashCountSkipped">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="dashFilterOrders('ERROR')">
Erori <span class="badge bg-light text-dark ms-1" id="dashCountError">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="dashFilterOrders('UNINVOICED')">
Nefacturate <span class="badge bg-light text-dark ms-1" id="dashCountUninvoiced">0</span>
</button>
<!-- Pagination top bar -->
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
<small class="text-muted" id="dashPageInfoTop"></small>
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
<select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</label>
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
</div>
</div>
<div class="card-body p-0">

View File

@@ -3,6 +3,11 @@
{% block nav_mappings %}active{% endblock %}
{% block content %}
<style>
.badge-pct { font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; }
.badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
</style>
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4>
<div>
@@ -36,6 +41,13 @@
</div>
</div>
<!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar">
<button class="filter-pill active" data-pct="all">Toate <span class="filter-count" id="mCntAll">0</span></button>
<button class="filter-pill" data-pct="complete">Complete &#10003; <span class="filter-count" id="mCntComplete">0</span></button>
<button class="filter-pill" data-pct="incomplete">Incomplete &#9888; <span class="filter-count" id="mCntIncomplete">0</span></button>
</div>
<!-- Table -->
<div class="card">
<div class="card-body p-0">

View File

@@ -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;">&#8635; 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 = `&#10003; ${data.total_skus_scanned || 0} scanate &nbsp;|&nbsp; ${data.new_missing || 0} noi lipsa &nbsp;|&nbsp; ${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';
}