feat(sqlite): refactor orders schema + dashboard period filter
Replace import_orders (insert-per-run) with orders table (one row per order, upsert on conflict). Eliminates dedup CTE on every dashboard query and prevents unbounded row growth at 4-500 orders/sync. Key changes: - orders table: PK order_number, upsert via ON CONFLICT DO UPDATE; COALESCE preserves id_comanda once set; times_skipped auto-increments - sync_run_orders: lightweight junction (sync_run_id, order_number) replaces sync_run_id column on orders - order_items: PK changed to (order_number, sku), INSERT OR IGNORE - Auto-migration in init_sqlite(): import_orders → orders on first boot, old table renamed to import_orders_bak - /api/dashboard/orders: period_days param (3/7/30/0=all, default 7) - Dashboard: period selector buttons in orders card header - start.sh: stop existing process on port 5003 before restart; remove --reload (broken on WSL2 /mnt/e/) - Add invoice_service, E2E Playwright tests, Oracle package updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,76 +1,38 @@
|
||||
let refreshInterval = null;
|
||||
let currentMapSku = '';
|
||||
let acTimeout = null;
|
||||
let dashPage = 1;
|
||||
let dashFilter = 'all';
|
||||
let dashSearch = '';
|
||||
let dashSortCol = 'order_date';
|
||||
let dashSortDir = 'desc';
|
||||
let dashSearchTimeout = null;
|
||||
let dashPeriodDays = 7;
|
||||
let currentQmSku = '';
|
||||
let currentQmOrderNumber = '';
|
||||
let qmAcTimeout = null;
|
||||
let syncEventSource = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDashboard();
|
||||
// Auto-refresh every 10 seconds
|
||||
refreshInterval = setInterval(loadDashboard, 10000);
|
||||
|
||||
const input = document.getElementById('mapCodmat');
|
||||
if (input) {
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(acTimeout);
|
||||
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
loadSchedulerStatus();
|
||||
loadSyncStatus();
|
||||
loadLastSync();
|
||||
loadDashOrders();
|
||||
refreshInterval = setInterval(() => {
|
||||
loadSyncStatus();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
await Promise.all([
|
||||
loadSyncStatus(),
|
||||
loadSyncHistory(),
|
||||
loadMissingSkus(),
|
||||
loadSchedulerStatus()
|
||||
]);
|
||||
}
|
||||
// ── Sync Status ──────────────────────────────────
|
||||
|
||||
async function loadSyncStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/status');
|
||||
const data = await res.json();
|
||||
|
||||
const stats = data.stats || {};
|
||||
|
||||
// Order-level stat cards from sync status
|
||||
document.getElementById('stat-imported').textContent = stats.imported != null ? stats.imported : 0;
|
||||
document.getElementById('stat-skipped').textContent = stats.skipped != null ? stats.skipped : 0;
|
||||
document.getElementById('stat-errors').textContent = stats.errors != null ? stats.errors : 0;
|
||||
|
||||
// Article-level stats from sync status
|
||||
if (stats.total_tracked_skus != null) {
|
||||
document.getElementById('stat-total-skus').textContent = stats.total_tracked_skus;
|
||||
}
|
||||
if (stats.unresolved_skus != null) {
|
||||
document.getElementById('stat-missing-skus').textContent = stats.unresolved_skus;
|
||||
const total = stats.total_tracked_skus || 0;
|
||||
const unresolved = stats.unresolved_skus || 0;
|
||||
document.getElementById('stat-mapped-skus').textContent = total - unresolved;
|
||||
}
|
||||
|
||||
// Restore scan-derived stats from sessionStorage (preserved across auto-refresh)
|
||||
const scanData = getScanData();
|
||||
if (scanData) {
|
||||
document.getElementById('stat-new').textContent = scanData.new_orders != null ? scanData.new_orders : (scanData.total_orders || '-');
|
||||
document.getElementById('stat-ready').textContent = scanData.importable != null ? scanData.importable : '-';
|
||||
if (scanData.skus) {
|
||||
document.getElementById('stat-total-skus').textContent = scanData.skus.total_skus || stats.total_tracked_skus || '-';
|
||||
document.getElementById('stat-missing-skus').textContent = scanData.skus.missing || stats.unresolved_skus || 0;
|
||||
const mapped = (scanData.skus.total_skus || 0) - (scanData.skus.missing || 0);
|
||||
document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : '-';
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync status badge
|
||||
const badge = document.getElementById('syncStatusBadge');
|
||||
const status = data.status || 'idle';
|
||||
badge.textContent = status;
|
||||
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
|
||||
|
||||
// Show/hide start/stop buttons
|
||||
if (status === 'running') {
|
||||
document.getElementById('btnStartSync').classList.add('d-none');
|
||||
document.getElementById('btnStopSync').classList.remove('d-none');
|
||||
@@ -79,12 +41,12 @@ async function loadSyncStatus() {
|
||||
document.getElementById('btnStartSync').classList.remove('d-none');
|
||||
document.getElementById('btnStopSync').classList.add('d-none');
|
||||
|
||||
// Show last run info
|
||||
const stats = data.stats || {};
|
||||
if (stats.last_run) {
|
||||
const lr = stats.last_run;
|
||||
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
|
||||
document.getElementById('syncProgressText').textContent =
|
||||
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} fara mapare, ${lr.errors || 0} erori`;
|
||||
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} nemapate, ${lr.errors || 0} erori`;
|
||||
} else {
|
||||
document.getElementById('syncProgressText').textContent = '';
|
||||
}
|
||||
@@ -94,98 +56,453 @@ async function loadSyncStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/history?per_page=10');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('syncRunsBody');
|
||||
// ── Last Sync Summary Card ───────────────────────
|
||||
|
||||
if (!data.runs || data.runs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
|
||||
async function loadLastSync() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/history?per_page=1');
|
||||
const data = await res.json();
|
||||
const runs = data.runs || [];
|
||||
|
||||
if (runs.length === 0) {
|
||||
document.getElementById('lastSyncDate').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.runs.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
|
||||
let duration = '-';
|
||||
if (r.started_at && r.finished_at) {
|
||||
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
|
||||
duration = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
|
||||
}
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
const r = runs[0];
|
||||
document.getElementById('lastSyncDate').textContent = r.started_at
|
||||
? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
|
||||
: '-';
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
<td class="text-success">${r.imported || 0}</td>
|
||||
<td class="text-warning">${r.skipped || 0}</td>
|
||||
<td class="text-danger">${r.errors || 0}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
document.getElementById('lastSyncStatus').innerHTML = `<span class="badge ${statusClass}">${esc(r.status)}</span>`;
|
||||
document.getElementById('lastSyncImported').textContent = r.imported || 0;
|
||||
document.getElementById('lastSyncSkipped').textContent = r.skipped || 0;
|
||||
document.getElementById('lastSyncErrors').textContent = r.errors || 0;
|
||||
|
||||
if (r.started_at && r.finished_at) {
|
||||
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
|
||||
document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
|
||||
} else {
|
||||
document.getElementById('lastSyncDuration').textContent = '-';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadSyncHistory error:', err);
|
||||
console.error('loadLastSync error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMissingSkus() {
|
||||
try {
|
||||
const res = await fetch('/api/validate/missing-skus?page=1&per_page=10');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingSkusBody');
|
||||
// ── Dashboard Orders Table ───────────────────────
|
||||
|
||||
// Update article-level stat card (unresolved count)
|
||||
if (data.total != null) {
|
||||
document.getElementById('stat-missing-skus').textContent = data.total;
|
||||
function debounceDashSearch() {
|
||||
clearTimeout(dashSearchTimeout);
|
||||
dashSearchTimeout = setTimeout(() => {
|
||||
dashSearch = document.getElementById('dashSearchInput').value;
|
||||
dashPage = 1;
|
||||
loadDashOrders();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function dashFilterOrders(filter) {
|
||||
dashFilter = filter;
|
||||
dashPage = 1;
|
||||
|
||||
// Update button styles
|
||||
const colorMap = {
|
||||
'all': 'primary',
|
||||
'IMPORTED': 'success',
|
||||
'SKIPPED': 'warning',
|
||||
'ERROR': 'danger',
|
||||
'UNINVOICED': 'info'
|
||||
};
|
||||
document.querySelectorAll('#dashFilterBtns button').forEach(btn => {
|
||||
const text = btn.textContent.trim().split(' ')[0];
|
||||
let btnFilter = 'all';
|
||||
if (text === 'Importate') btnFilter = 'IMPORTED';
|
||||
else if (text === 'Omise') btnFilter = 'SKIPPED';
|
||||
else if (text === 'Erori') btnFilter = 'ERROR';
|
||||
else if (text === 'Nefacturate') btnFilter = 'UNINVOICED';
|
||||
|
||||
const color = colorMap[btnFilter] || 'primary';
|
||||
if (btnFilter === filter) {
|
||||
btn.className = `btn btn-sm btn-${color}`;
|
||||
} else {
|
||||
btn.className = `btn btn-sm btn-outline-${color}`;
|
||||
}
|
||||
});
|
||||
|
||||
loadDashOrders();
|
||||
}
|
||||
|
||||
function dashSortBy(col) {
|
||||
if (dashSortCol === col) {
|
||||
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
dashSortCol = col;
|
||||
dashSortDir = 'asc';
|
||||
}
|
||||
// Update sort icons
|
||||
document.querySelectorAll('#dashOrdersBody').forEach(() => {}); // noop
|
||||
document.querySelectorAll('.sort-icon').forEach(span => {
|
||||
const c = span.dataset.col;
|
||||
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
|
||||
});
|
||||
dashPage = 1;
|
||||
loadDashOrders();
|
||||
}
|
||||
|
||||
function dashSetPeriod(days) {
|
||||
dashPeriodDays = days;
|
||||
dashPage = 1;
|
||||
document.querySelectorAll('#dashPeriodBtns button').forEach(btn => {
|
||||
const val = parseInt(btn.dataset.days);
|
||||
btn.className = val === days
|
||||
? 'btn btn-sm btn-secondary'
|
||||
: 'btn btn-sm btn-outline-secondary';
|
||||
});
|
||||
loadDashOrders();
|
||||
}
|
||||
|
||||
async function loadDashOrders() {
|
||||
const params = new URLSearchParams({
|
||||
page: dashPage,
|
||||
per_page: 50,
|
||||
search: dashSearch,
|
||||
status: dashFilter,
|
||||
sort_by: dashSortCol,
|
||||
sort_dir: dashSortDir,
|
||||
period_days: dashPeriodDays
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/orders?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
const counts = data.counts || {};
|
||||
document.getElementById('dashCountAll').textContent = counts.total || 0;
|
||||
document.getElementById('dashCountImported').textContent = counts.imported || 0;
|
||||
document.getElementById('dashCountSkipped').textContent = counts.skipped || 0;
|
||||
document.getElementById('dashCountError').textContent = counts.error || 0;
|
||||
document.getElementById('dashCountUninvoiced').textContent = counts.uninvoiced || 0;
|
||||
|
||||
const tbody = document.getElementById('dashOrdersBody');
|
||||
const orders = data.orders || [];
|
||||
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = orders.map(o => {
|
||||
const dateStr = fmtDate(o.order_date);
|
||||
const statusBadge = orderStatusBadge(o.status);
|
||||
|
||||
// Invoice info
|
||||
let invoiceBadge = '';
|
||||
let invoiceTotal = '';
|
||||
if (o.status !== 'IMPORTED') {
|
||||
invoiceBadge = '<span class="text-muted">-</span>';
|
||||
} else if (o.invoice && o.invoice.facturat) {
|
||||
invoiceBadge = `<span class="badge bg-success">Facturat</span>`;
|
||||
if (o.invoice.serie_act || o.invoice.numar_act) {
|
||||
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
|
||||
}
|
||||
invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
|
||||
} else {
|
||||
invoiceBadge = '<span class="badge bg-danger">Nefacturat</span>';
|
||||
}
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${dateStr}</td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${o.id_comanda || '-'}</td>
|
||||
<td>${invoiceBadge}</td>
|
||||
<td>${invoiceTotal}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
|
||||
// Pagination
|
||||
const totalPages = data.pages || 1;
|
||||
document.getElementById('dashPageInfo').textContent = `${data.total || 0} comenzi | Pagina ${dashPage} din ${totalPages}`;
|
||||
|
||||
if (unresolved.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
|
||||
const pagDiv = document.getElementById('dashPagination');
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${dashPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} else {
|
||||
pagDiv.innerHTML = '';
|
||||
}
|
||||
|
||||
// Update sort icons
|
||||
document.querySelectorAll('.sort-icon').forEach(span => {
|
||||
const c = span.dataset.col;
|
||||
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
|
||||
});
|
||||
} catch (err) {
|
||||
document.getElementById('dashOrdersBody').innerHTML =
|
||||
`<tr><td colspan="8" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function dashGoPage(p) {
|
||||
dashPage = p;
|
||||
loadDashOrders();
|
||||
}
|
||||
|
||||
// ── Helper functions ─────────────────────────────
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const hasTime = dateStr.includes(':');
|
||||
if (hasTime) {
|
||||
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch { return dateStr; }
|
||||
}
|
||||
|
||||
function orderStatusBadge(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
||||
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCodmatCell(item) {
|
||||
if (!item.codmat_details || item.codmat_details.length === 0) {
|
||||
return `<code>${esc(item.codmat || '-')}</code>`;
|
||||
}
|
||||
if (item.codmat_details.length === 1) {
|
||||
const d = item.codmat_details[0];
|
||||
return `<code>${esc(d.codmat)}</code>`;
|
||||
}
|
||||
return item.codmat_details.map(d =>
|
||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Order Detail Modal ───────────────────────────
|
||||
|
||||
async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
||||
document.getElementById('detailCustomer').textContent = '...';
|
||||
document.getElementById('detailDate').textContent = '';
|
||||
document.getElementById('detailStatus').innerHTML = '';
|
||||
document.getElementById('detailIdComanda').textContent = '-';
|
||||
document.getElementById('detailIdPartener').textContent = '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
|
||||
const modalEl = document.getElementById('orderDetailModal');
|
||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
||||
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('detailError').textContent = data.error;
|
||||
document.getElementById('detailError').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = unresolved.slice(0, 10).map(s => {
|
||||
let firstCustomer = '-';
|
||||
try {
|
||||
const customers = JSON.parse(s.customers || '[]');
|
||||
if (customers.length > 0) firstCustomer = customers[0];
|
||||
} catch (e) { /* ignore */ }
|
||||
const order = data.order || {};
|
||||
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
||||
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
||||
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
||||
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
||||
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
||||
|
||||
if (order.error_message) {
|
||||
document.getElementById('detailError').textContent = order.error_message;
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
return `<tr>
|
||||
<td><code>${esc(s.sku)}</code></td>
|
||||
<td>${esc(s.product_name || '-')}</td>
|
||||
<td>${s.order_count != null ? s.order_count : '-'}</td>
|
||||
<td><small>${esc(firstCustomer)}</small></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" title="Creeaza mapare"
|
||||
onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('loadMissingSkus error:', err);
|
||||
document.getElementById('detailError').textContent = err.message;
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedulerStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/schedule');
|
||||
const data = await res.json();
|
||||
// ── Quick Map Modal ──────────────────────────────
|
||||
|
||||
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
||||
if (data.interval_minutes) {
|
||||
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
||||
function openQuickMap(sku, productName, orderNumber) {
|
||||
currentQmSku = sku;
|
||||
currentQmOrderNumber = orderNumber;
|
||||
document.getElementById('qmSku').textContent = sku;
|
||||
document.getElementById('qmProductName').textContent = productName || '-';
|
||||
document.getElementById('qmPctWarning').style.display = 'none';
|
||||
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
container.innerHTML = '';
|
||||
addQmCodmatLine();
|
||||
|
||||
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
|
||||
}
|
||||
|
||||
function addQmCodmatLine() {
|
||||
const container = document.getElementById('qmCodmatLines');
|
||||
const idx = container.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded p-2 mb-2 qm-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 qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
||||
<small class="text-muted qm-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 qm-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 qm-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('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
const input = div.querySelector('.qm-codmat');
|
||||
const dropdown = div.querySelector('.qm-ac-dropdown');
|
||||
const selected = div.querySelector('.qm-selected');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(qmAcTimeout);
|
||||
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function qmAutocomplete(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="qmSelectArticle(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 qmSelectArticle(el, codmat, label) {
|
||||
const line = el.closest('.qm-line');
|
||||
line.querySelector('.qm-codmat').value = codmat;
|
||||
line.querySelector('.qm-selected').textContent = label;
|
||||
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMapping() {
|
||||
const lines = document.querySelectorAll('.qm-line');
|
||||
const mappings = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const codmat = line.querySelector('.qm-codmat').value.trim();
|
||||
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
|
||||
const procent = parseFloat(line.querySelector('.qm-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('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
||||
document.getElementById('qmPctWarning').style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('qmPctWarning').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: currentQmSku, 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: currentQmSku, mappings })
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
|
||||
loadDashOrders();
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadSchedulerStatus error:', err);
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync Controls ────────────────────────────────
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const res = await fetch('/api/sync/start', { method: 'POST' });
|
||||
@@ -194,7 +511,6 @@ async function startSync() {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
// Show banner with link to live logs
|
||||
if (data.run_id) {
|
||||
const banner = document.getElementById('syncStartedBanner');
|
||||
const link = document.getElementById('syncRunLink');
|
||||
@@ -202,61 +518,72 @@ async function startSync() {
|
||||
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
|
||||
banner.classList.remove('d-none');
|
||||
}
|
||||
// Subscribe to SSE for live progress + auto-refresh on completion
|
||||
listenToSyncStream(data.run_id);
|
||||
}
|
||||
loadDashboard();
|
||||
loadSyncStatus();
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function listenToSyncStream(runId) {
|
||||
// Close any previous SSE connection
|
||||
if (syncEventSource) { syncEventSource.close(); syncEventSource = null; }
|
||||
|
||||
syncEventSource = new EventSource('/api/sync/stream');
|
||||
|
||||
syncEventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'phase') {
|
||||
document.getElementById('syncProgressText').textContent = data.message || '';
|
||||
}
|
||||
|
||||
if (data.type === 'order_result') {
|
||||
// Update progress text with current order info
|
||||
const status = data.status === 'IMPORTED' ? 'OK' : data.status === 'SKIPPED' ? 'OMIS' : 'ERR';
|
||||
document.getElementById('syncProgressText').textContent =
|
||||
`[${data.progress || ''}] #${data.order_number} ${data.customer_name || ''} → ${status}`;
|
||||
}
|
||||
|
||||
if (data.type === 'completed' || data.type === 'failed') {
|
||||
syncEventSource.close();
|
||||
syncEventSource = null;
|
||||
// Refresh all dashboard sections
|
||||
loadLastSync();
|
||||
loadDashOrders();
|
||||
loadSyncStatus();
|
||||
// Hide banner after 5s
|
||||
setTimeout(() => {
|
||||
document.getElementById('syncStartedBanner')?.classList.add('d-none');
|
||||
}, 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SSE parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
syncEventSource.onerror = () => {
|
||||
syncEventSource.close();
|
||||
syncEventSource = null;
|
||||
// Refresh anyway — sync may have finished
|
||||
loadLastSync();
|
||||
loadDashOrders();
|
||||
loadSyncStatus();
|
||||
};
|
||||
}
|
||||
|
||||
async function stopSync() {
|
||||
try {
|
||||
await fetch('/api/sync/stop', { method: 'POST' });
|
||||
loadDashboard();
|
||||
loadSyncStatus();
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanOrders() {
|
||||
const btn = document.getElementById('btnScan');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Scanning...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/validate/scan', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
// Persist scan results so auto-refresh doesn't overwrite them
|
||||
saveScanData(data);
|
||||
|
||||
// Update stat cards immediately from scan response
|
||||
document.getElementById('stat-new').textContent = data.new_orders != null ? data.new_orders : (data.total_orders || 0);
|
||||
document.getElementById('stat-ready').textContent = data.importable != null ? data.importable : 0;
|
||||
|
||||
if (data.skus) {
|
||||
document.getElementById('stat-total-skus').textContent = data.skus.total_skus || 0;
|
||||
document.getElementById('stat-missing-skus').textContent = data.skus.missing || 0;
|
||||
const mapped = (data.skus.total_skus || 0) - (data.skus.missing || 0);
|
||||
document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : 0;
|
||||
}
|
||||
|
||||
let msg = `Scan complet: ${data.total_orders || 0} comenzi`;
|
||||
if (data.new_orders != null) msg += `, ${data.new_orders} noi`;
|
||||
msg += `, ${data.importable || 0} ready`;
|
||||
if (data.skus && data.skus.missing > 0) {
|
||||
msg += `, ${data.skus.missing} SKU-uri lipsa`;
|
||||
}
|
||||
alert(msg);
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
alert('Eroare scan: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-search"></i> Scan';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScheduler() {
|
||||
const enabled = document.getElementById('schedulerToggle').checked;
|
||||
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
|
||||
@@ -279,106 +606,19 @@ async function updateSchedulerInterval() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Map Modal ---
|
||||
|
||||
function openMapModal(sku, productName) {
|
||||
currentMapSku = sku;
|
||||
document.getElementById('mapSku').textContent = sku;
|
||||
document.getElementById('mapCodmat').value = productName || '';
|
||||
document.getElementById('mapCantitate').value = '1';
|
||||
document.getElementById('mapProcent').value = '100';
|
||||
document.getElementById('mapSelectedArticle').textContent = '';
|
||||
document.getElementById('mapAutocomplete').classList.add('d-none');
|
||||
|
||||
if (productName) {
|
||||
autocompleteMap(productName);
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
||||
}
|
||||
|
||||
async function autocompleteMap(q) {
|
||||
const dropdown = document.getElementById('mapAutocomplete');
|
||||
if (!dropdown) return;
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
|
||||
async function loadSchedulerStatus() {
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const res = await fetch('/api/sync/schedule');
|
||||
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="selectMapArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span>
|
||||
<br><span class="denumire">${esc(r.denumire)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch (err) {
|
||||
dropdown.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function selectMapArticle(codmat, denumire) {
|
||||
document.getElementById('mapCodmat').value = codmat;
|
||||
document.getElementById('mapSelectedArticle').textContent = denumire;
|
||||
document.getElementById('mapAutocomplete').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveQuickMap() {
|
||||
const codmat = document.getElementById('mapCodmat').value.trim();
|
||||
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1;
|
||||
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
|
||||
|
||||
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sku: currentMapSku,
|
||||
codmat: codmat,
|
||||
cantitate_roa: cantitate,
|
||||
procent_pret: procent
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||
loadMissingSkus();
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
||||
if (data.interval_minutes) {
|
||||
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
console.error('loadSchedulerStatus error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- sessionStorage helpers for scan data ---
|
||||
|
||||
function saveScanData(data) {
|
||||
try {
|
||||
sessionStorage.setItem('lastScanData', JSON.stringify(data));
|
||||
sessionStorage.setItem('lastScanTime', Date.now().toString());
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function getScanData() {
|
||||
try {
|
||||
const t = parseInt(sessionStorage.getItem('lastScanTime') || '0');
|
||||
// Expire scan data after 5 minutes
|
||||
if (Date.now() - t > 5 * 60 * 1000) return null;
|
||||
const raw = sessionStorage.getItem('lastScanData');
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
Reference in New Issue
Block a user