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:
2026-03-13 16:18:57 +02:00
parent 650e98539e
commit 82196b9dc0
32 changed files with 4164 additions and 1192 deletions

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');

View File

@@ -1,9 +1,14 @@
// logs.js - Unified Logs page with SSE live feed
// logs.js - Structured order viewer with text log fallback
let currentRunId = null;
let eventSource = null;
let runsPage = 1;
let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
let logPollTimer = null;
let currentFilter = 'all';
let ordersPage = 1;
let currentQmSku = '';
let currentQmOrderNumber = '';
let ordersSortColumn = 'created_at';
let ordersSortDirection = 'asc';
function esc(s) {
if (s == null) return '';
@@ -13,23 +18,6 @@ function esc(s) {
.replace(/'/g, '&#39;');
}
function fmtTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) { return ''; }
}
function fmtDatetime(iso) {
if (!iso) return '-';
try {
return new Date(iso).toLocaleString('ro-RO', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) { return iso; }
}
function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -39,13 +27,16 @@ function fmtDuration(startedAt, finishedAt) {
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
}
function statusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">IMPORTED</span>';
case 'SKIPPED': return '<span class="badge bg-warning text-dark">SKIPPED</span>';
case 'ERROR': return '<span class="badge bg-danger">ERROR</span>';
default: return `<span class="badge bg-secondary">${esc(status || '-')}</span>`;
}
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', second: '2-digit' });
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
function runStatusBadge(status) {
@@ -57,323 +48,461 @@ function runStatusBadge(status) {
}
}
// ── Runs Table ──────────────────────────────────
async function loadRuns(page) {
if (page != null) runsPage = page;
const perPage = 20;
try {
const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const runs = data.runs || [];
const total = data.total || runs.length;
// Populate dropdown
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
runs.map(r => {
const date = fmtDatetime(r.started_at);
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date}${stats}</option>`;
}).join('');
// Populate table
const tbody = document.getElementById('runsTableBody');
if (runs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
} else {
tbody.innerHTML = 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'}) : '-';
const duration = fmtDuration(r.started_at, r.finished_at);
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
const activeClass = r.run_id === currentRunId ? 'table-active' : '';
return `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${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('');
}
// Pagination
const pagDiv = document.getElementById('runsTablePagination');
const totalPages = Math.ceil(total / perPage);
if (totalPages > 1) {
pagDiv.innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
<small class="text-muted">${runsPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
} catch (err) {
document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
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>`;
}
}
// ── Run Selection ───────────────────────────────
// ── Runs Dropdown ───────────────────────────────
async function loadRuns() {
// Load all recent runs for dropdown
try {
const res = await fetch(`/api/sync/history?page=1&per_page=100`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const runs = data.runs || [];
const dd = document.getElementById('runsDropdown');
if (runs.length === 0) {
dd.innerHTML = '<option value="">Niciun sync run</option>';
} else {
dd.innerHTML = '<option value="">-- Selecteaza un run --</option>' +
runs.map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '?';
const st = (r.status || '').toUpperCase();
const statusEmoji = st === 'COMPLETED' ? '✓' : st === 'RUNNING' ? '⟳' : '✗';
const imp = r.imported || 0;
const skip = r.skipped || 0;
const err = r.errors || 0;
const label = `${started}${statusEmoji} ${r.status} (${imp} imp, ${skip} skip, ${err} err)`;
const selected = r.run_id === currentRunId ? 'selected' : '';
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
}).join('');
}
} catch (err) {
const dd = document.getElementById('runsDropdown');
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
}
}
// ── Run Selection ────────────────────────────────
async function selectRun(runId) {
if (eventSource) { eventSource.close(); eventSource = null; }
currentRunId = runId;
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
currentRunId = runId;
currentFilter = 'all';
ordersPage = 1;
// Update URL without reload
const url = new URL(window.location);
if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); }
history.replaceState(null, '', url);
// Highlight active row in table
document.querySelectorAll('#runsTableBody tr').forEach(tr => {
tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`);
});
// Update dropdown
document.getElementById('runSelector').value = runId || '';
// Sync dropdown selection
const dd = document.getElementById('runsDropdown');
if (dd && dd.value !== runId) dd.value = runId;
if (!runId) {
document.getElementById('runDetailSection').style.display = 'none';
document.getElementById('logViewerSection').style.display = 'none';
return;
}
document.getElementById('runDetailSection').style.display = '';
document.getElementById('logViewerSection').style.display = '';
document.getElementById('logRunId').textContent = runId;
document.getElementById('logStatusBadge').innerHTML = '<span class="badge bg-secondary">...</span>';
document.getElementById('textLogSection').style.display = 'none';
// Check if this run is currently active
try {
const statusRes = await fetch('/api/sync/status');
const statusData = await statusRes.json();
if (statusData.status === 'running' && statusData.run_id === runId) {
startLiveFeed(runId);
return;
}
} catch (e) { /* fall through to historical load */ }
await loadRunOrders(runId, 'all', 1);
// Load historical data
document.getElementById('liveFeedCard').style.display = 'none';
await loadRunLog(runId);
// Also load text log in background
fetchTextLog(runId);
}
// ── Live SSE Feed ───────────────────────────────
// ── Per-Order Filtering (R1) ─────────────────────
function startLiveFeed(runId) {
liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
async function loadRunOrders(runId, statusFilter, page) {
if (statusFilter != null) currentFilter = statusFilter;
if (page != null) ordersPage = page;
// Show live feed card, clear it
const feedCard = document.getElementById('liveFeedCard');
feedCard.style.display = '';
document.getElementById('liveFeed').innerHTML = '';
document.getElementById('logsBody').innerHTML = '';
// Reset summary
document.getElementById('sum-total').textContent = '-';
document.getElementById('sum-imported').textContent = '0';
document.getElementById('sum-skipped').textContent = '0';
document.getElementById('sum-errors').textContent = '0';
document.getElementById('sum-duration').textContent = 'live...';
connectSSE();
}
function connectSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/api/sync/stream');
eventSource.onmessage = function(e) {
let event;
try { event = JSON.parse(e.data); } catch (err) { return; }
if (event.type === 'keepalive') return;
if (event.type === 'phase') {
appendFeedEntry('phase', event.message);
}
else if (event.type === 'order_result') {
const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌';
const progressText = event.progress ? `[${event.progress}]` : '';
appendFeedEntry(
event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '',
`${progressText} #${event.order_number} ${event.customer_name || ''}${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}`
);
addOrderRow(event);
updateLiveSummary(event);
}
else if (event.type === 'completed') {
appendFeedEntry('phase', '🏁 Sync completed');
eventSource.close();
eventSource = null;
document.querySelector('.live-pulse')?.remove();
// Reload full data from REST after short delay
setTimeout(() => {
loadRunLog(currentRunId);
loadRuns();
}, 500);
}
else if (event.type === 'failed') {
appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error'));
eventSource.close();
eventSource = null;
document.querySelector('.live-pulse')?.remove();
setTimeout(() => {
loadRunLog(currentRunId);
loadRuns();
}, 500);
}
};
eventSource.onerror = function() {
// SSE disconnected — try to load historical data
eventSource.close();
eventSource = null;
setTimeout(() => loadRunLog(currentRunId), 1000);
};
}
function appendFeedEntry(type, message) {
const feed = document.getElementById('liveFeed');
const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const typeClass = type ? ` ${type}` : '';
const iconMap = { phase: '', error: '❌', success: '✅' };
const icon = iconMap[type] || '▶';
const entry = document.createElement('div');
entry.className = `feed-entry${typeClass}`;
entry.innerHTML = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
feed.appendChild(entry);
// Auto-scroll to bottom
feed.scrollTop = feed.scrollHeight;
}
function addOrderRow(event) {
const tbody = document.getElementById('logsBody');
const status = (event.status || '').toUpperCase();
let details = '';
if (event.error_message) {
details = `<span class="text-danger">${esc(event.error_message)}</span>`;
}
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
}
if (event.id_comanda) {
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
}
if (!details) details = '<span class="text-muted">-</span>';
const tr = document.createElement('tr');
tr.setAttribute('data-status', status);
tr.innerHTML = `
<td><code>${esc(event.order_number || '-')}</code></td>
<td>${esc(event.customer_name || '-')}</td>
<td class="text-center">${event.items_count ?? '-'}</td>
<td>${statusBadge(status)}</td>
<td>${details}</td>
`;
tbody.appendChild(tr);
}
function updateLiveSummary(event) {
liveCounts.total++;
if (event.status === 'IMPORTED') liveCounts.imported++;
else if (event.status === 'SKIPPED') liveCounts.skipped++;
else if (event.status === 'ERROR') liveCounts.errors++;
document.getElementById('sum-total').textContent = liveCounts.total;
document.getElementById('sum-imported').textContent = liveCounts.imported;
document.getElementById('sum-skipped').textContent = liveCounts.skipped;
document.getElementById('sum-errors').textContent = liveCounts.errors;
}
// ── Historical Run Log ──────────────────────────
async function loadRunLog(runId) {
const tbody = document.getElementById('logsBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
// Update filter button styles
document.querySelectorAll('#orderFilterBtns button').forEach(btn => {
btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary')
.replace(' btn-success', ' btn-outline-success')
.replace(' btn-warning', ' btn-outline-warning')
.replace(' btn-danger', ' btn-outline-danger');
});
try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/orders?status=${currentFilter}&page=${ordersPage}&per_page=50&sort_by=${ordersSortColumn}&sort_dir=${ordersSortDirection}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const run = data.run || {};
const orders = data.orders || [];
const counts = data.counts || {};
document.getElementById('countAll').textContent = counts.total || 0;
document.getElementById('countImported').textContent = counts.imported || 0;
document.getElementById('countSkipped').textContent = counts.skipped || 0;
document.getElementById('countError').textContent = counts.error || 0;
// Populate summary bar
document.getElementById('sum-total').textContent = run.total_orders ?? '-';
document.getElementById('sum-imported').textContent = run.imported ?? '-';
document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
document.getElementById('sum-errors').textContent = run.errors ?? '-';
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
if (orders.length === 0) {
const runError = run.error_message
? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
tbody.innerHTML = runError;
updateFilterCount();
return;
// Highlight active filter
const filterMap = { 'all': 0, 'IMPORTED': 1, 'SKIPPED': 2, 'ERROR': 3 };
const btns = document.querySelectorAll('#orderFilterBtns button');
const idx = filterMap[currentFilter] || 0;
if (btns[idx]) {
const colorMap = ['primary', 'success', 'warning', 'danger'];
btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
}
tbody.innerHTML = orders.map(order => {
const status = (order.status || '').toUpperCase();
let missingSkuTags = '';
if (order.missing_skus) {
try {
const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus;
if (Array.isArray(skus) && skus.length > 0) {
missingSkuTags = '<div class="mt-1">' +
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
}
} catch (e) { /* skip */ }
}
const details = order.error_message
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
: missingSkuTags || '<span class="text-muted">-</span>';
const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || [];
return `<tr data-status="${esc(status)}">
<td><code>${esc(order.order_number || '-')}</code></td>
<td>${esc(order.customer_name || '-')}</td>
<td class="text-center">${order.items_count ?? '-'}</td>
<td>${statusBadge(status)}</td>
<td>${details}</td>
</tr>`;
}).join('');
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map((o, i) => {
const dateStr = fmtDate(o.order_date);
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
<td>${(ordersPage - 1) * 50 + i + 1}</td>
<td>${dateStr}</td>
<td><code>${esc(o.order_number)}</code></td>
<td>${esc(o.customer_name)}</td>
<td>${o.items_count || 0}</td>
<td>${orderStatusBadge(o.status)}</td>
</tr>`;
}).join('');
}
// Reset filter
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all');
});
applyFilter('all');
// Orders pagination
const totalPages = data.pages || 1;
const infoEl = document.getElementById('ordersPageInfo');
infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
const pagDiv = document.getElementById('ordersPagination');
if (totalPages > 1) {
pagDiv.innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${ordersPage <= 1 ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage - 1})"><i class="bi bi-chevron-left"></i></button>
<small class="text-muted">${ordersPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${ordersPage >= totalPages ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
// Update run status badge
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
const runData = await runRes.json();
if (runData.run) {
document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status);
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3"><i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}</td></tr>`;
document.getElementById('runOrdersBody').innerHTML =
`<tr><td colspan="6" class="text-center text-danger">${esc(err.message)}</td></tr>`;
}
}
// ── Filters ─────────────────────────────────────
function applyFilter(filter) {
const rows = document.querySelectorAll('#logsBody tr[data-status]');
let visible = 0;
rows.forEach(row => {
const show = filter === 'all' || row.dataset.status === filter;
row.style.display = show ? '' : 'none';
if (show) visible++;
});
updateFilterCount(visible, rows.length, filter);
function filterOrders(status) {
loadRunOrders(currentRunId, status, 1);
}
function updateFilterCount(visible, total, filter) {
const el = document.getElementById('filterCount');
if (!el) return;
if (visible == null) { el.textContent = ''; return; }
el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`;
function sortOrdersBy(col) {
if (ordersSortColumn === col) {
ordersSortDirection = ordersSortDirection === 'asc' ? 'desc' : 'asc';
} else {
ordersSortColumn = col;
ordersSortDirection = 'asc';
}
// Update sort icons
document.querySelectorAll('#logViewerSection .sort-icon').forEach(span => {
const c = span.dataset.col;
span.textContent = c === ordersSortColumn ? (ordersSortDirection === 'asc' ? '\u2191' : '\u2193') : '';
});
loadRunOrders(currentRunId, null, 1);
}
// ── Text Log (collapsible) ──────────────────────
function toggleTextLog() {
const section = document.getElementById('textLogSection');
section.style.display = section.style.display === 'none' ? '' : 'none';
if (section.style.display !== 'none' && currentRunId) {
fetchTextLog(currentRunId);
}
}
async function fetchTextLog(runId) {
// Clear any existing poll timer to prevent accumulation
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
document.getElementById('logContent').textContent = data.text || '(log gol)';
if (!data.finished) {
if (document.getElementById('autoRefreshToggle')?.checked) {
logPollTimer = setInterval(async () => {
try {
const r = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`);
const d = await r.json();
if (currentRunId !== runId) { clearInterval(logPollTimer); return; }
document.getElementById('logContent').textContent = d.text || '(log gol)';
const el = document.getElementById('logContent');
el.scrollTop = el.scrollHeight;
if (d.finished) {
clearInterval(logPollTimer);
logPollTimer = null;
loadRuns();
loadRunOrders(runId, currentFilter, ordersPage);
}
} catch (e) { console.error('Poll error:', e); }
}, 2500);
}
}
} catch (err) {
document.getElementById('logContent').textContent = 'Eroare: ' + err.message;
}
}
// ── Multi-CODMAT helper (D1) ─────────────────────
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>`;
}
// Multi-CODMAT: compact list
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 (R9) ─────────────────────
async function openOrderDetail(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;
}
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(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) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
// ── Quick Map Modal (from order detail) ──────────
let qmAcTimeout = null;
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';
// Reset CODMAT lines
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
addQmCodmatLine();
// Show quick map on top of order detail (modal stacking)
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);
// Setup autocomplete on the new input
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; }
// Validate percentage sum for multi-line
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();
// Refresh order detail items in the still-open modal
if (currentQmOrderNumber) openOrderDetail(currentQmOrderNumber);
// Refresh orders view
loadRunOrders(currentRunId, currentFilter, ordersPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// ── Init ────────────────────────────────────────
@@ -381,25 +510,20 @@ function updateFilterCount(visible, total, filter) {
document.addEventListener('DOMContentLoaded', () => {
loadRuns();
// Dropdown change
document.getElementById('runSelector').addEventListener('change', function() {
selectRun(this.value);
});
// Filter buttons
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
applyFilter(this.dataset.filter);
});
});
// Auto-select run from URL or server
const preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
if (runFromUrl) {
selectRun(runFromUrl);
}
document.getElementById('autoRefreshToggle')?.addEventListener('change', (e) => {
if (e.target.checked) {
// Resume polling if we have an active run
if (currentRunId) fetchTextLog(currentRunId);
} else {
// Pause polling
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
}
});
});

View File

@@ -1,9 +1,16 @@
let currentPage = 1;
let currentSearch = '';
let searchTimeout = null;
let sortColumn = 'sku';
let sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing
// Load on page ready
document.addEventListener('DOMContentLoaded', loadMappings);
document.addEventListener('DOMContentLoaded', () => {
loadMappings();
initAddModal();
initDeleteModal();
});
function debounceSearch() {
clearTimeout(searchTimeout);
@@ -14,52 +21,132 @@ function debounceSearch() {
}, 300);
}
// ── Sorting (R7) ─────────────────────────────────
function sortBy(col) {
if (sortColumn === col) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = col;
sortDirection = 'asc';
}
currentPage = 1;
loadMappings();
}
function updateSortIcons() {
document.querySelectorAll('.sort-icon').forEach(span => {
const col = span.dataset.col;
if (col === sortColumn) {
span.textContent = sortDirection === 'asc' ? '\u2191' : '\u2193';
} else {
span.textContent = '';
}
});
}
// ── Load & Render ────────────────────────────────
async function loadMappings() {
const showInactive = document.getElementById('showInactive')?.checked;
const showDeleted = document.getElementById('showDeleted')?.checked;
const params = new URLSearchParams({
search: currentSearch,
page: currentPage,
per_page: 50
per_page: 50,
sort_by: sortColumn,
sort_dir: sortDirection
});
if (showDeleted) params.set('show_deleted', 'true');
try {
const res = await fetch(`/api/mappings?${params}`);
const data = await res.json();
renderTable(data.mappings);
let mappings = data.mappings || [];
// Client-side filter for inactive unless toggle is on
// (keep deleted rows visible when showDeleted is on, even if inactive)
if (!showInactive) {
mappings = mappings.filter(m => m.activ || m.sters);
}
renderTable(mappings, showDeleted);
renderPagination(data);
updateSortIcons();
} catch (err) {
document.getElementById('mappingsBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
}
}
function renderTable(mappings) {
function renderTable(mappings, showDeleted) {
const tbody = document.getElementById('mappingsBody');
if (!mappings || mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
return;
}
tbody.innerHTML = mappings.map(m => `
<tr>
<td><strong>${esc(m.sku)}</strong></td>
// Group by SKU for visual grouping (R6)
let html = '';
let prevSku = null;
let groupIdx = 0;
let skuGroupCounts = {};
// Count items per SKU
mappings.forEach(m => {
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
});
mappings.forEach((m, i) => {
const isNewGroup = m.sku !== prevSku;
if (isNewGroup) groupIdx++;
const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
const isMulti = skuGroupCounts[m.sku] > 1;
const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
const deletedClass = m.sters ? 'mapping-deleted' : '';
// SKU cell: show only on first row of group
let skuCell, productCell;
if (isNewGroup) {
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}</td>`;
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
} else {
skuCell = '';
productCell = '';
}
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}">
${skuCell}
${productCell}
<td><code>${esc(m.codmat)}</code></td>
<td>${esc(m.denumire || '-')}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})">${m.cantitate_roa}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})">${m.procent_pret}%</td>
<td>${esc(m.um || '-')}</td>
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td>
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td>
<td>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" style="cursor:pointer"
onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})">
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'}
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
${m.activ ? 'Activ' : 'Inactiv'}
</span>
</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Dezactiveaza">
<i class="bi bi-trash"></i>
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
<i class="bi bi-trash"></i>
</button>`}
</td>
</tr>
`).join('');
</tr>`;
prevSku = m.sku;
});
tbody.innerHTML = html;
}
function renderPagination(data) {
@@ -70,11 +157,9 @@ function renderPagination(data) {
if (data.pages <= 1) { ul.innerHTML = ''; return; }
let html = '';
// Previous
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`;
// Pages (show max 7)
let start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 6);
@@ -84,7 +169,6 @@ function renderPagination(data) {
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
}
// Next
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">&raquo;</a></li>`;
@@ -96,73 +180,186 @@ function goPage(p) {
loadMappings();
}
// Autocomplete for CODMAT
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('inputCodmat');
if (!input) return;
// ── Multi-CODMAT Add Modal (R11) ─────────────────
let acTimeouts = {};
function initAddModal() {
const modal = document.getElementById('addModal');
if (!modal) return;
modal.addEventListener('show.bs.modal', () => {
if (!editingMapping) {
clearAddForm();
}
});
modal.addEventListener('hidden.bs.modal', () => {
editingMapping = null;
document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
});
}
function clearAddForm() {
document.getElementById('inputSku').value = '';
document.getElementById('inputSku').readOnly = false;
document.getElementById('addModalProductName').style.display = 'none';
document.getElementById('pctWarning').style.display = 'none';
document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
const container = document.getElementById('codmatLines');
container.innerHTML = '';
addCodmatLine();
}
function openEditModal(sku, codmat, cantitate, procent) {
editingMapping = { sku, codmat };
document.getElementById('addModalTitle').textContent = 'Editare Mapare';
document.getElementById('inputSku').value = sku;
document.getElementById('inputSku').readOnly = false;
document.getElementById('pctWarning').style.display = 'none';
const container = document.getElementById('codmatLines');
container.innerHTML = '';
addCodmatLine();
// Pre-fill the CODMAT line
const line = container.querySelector('.codmat-line');
if (line) {
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
}
new bootstrap.Modal(document.getElementById('addModal')).show();
}
function addCodmatLine() {
const container = document.getElementById('codmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 codmat-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 cl-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
<small class="text-muted cl-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 cl-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 cl-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('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : ''}
</div>
</div>
`;
container.appendChild(div);
// Setup autocomplete
const input = div.querySelector('.cl-codmat');
const dropdown = div.querySelector('.cl-ac-dropdown');
const selected = div.querySelector('.cl-selected');
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocomplete(input.value), 250);
const key = 'cl_' + idx;
clearTimeout(acTimeouts[key]);
acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200);
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
});
}
async function autocomplete(q) {
const dropdown = document.getElementById('autocompleteDropdown');
async function clAutocomplete(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; }
if (!data.results || data.results.length === 0) {
dropdown.classList.add('d-none');
return;
}
dropdown.innerHTML = data.results.map(r => `
<div class="autocomplete-item" onmousedown="selectArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
<span class="codmat">${esc(r.codmat)}</span>
<br><span class="denumire">${esc(r.denumire)}</span>
</div>
`).join('');
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="clSelectArticle(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 (err) {
dropdown.classList.add('d-none');
}
} catch { dropdown.classList.add('d-none'); }
}
function selectArticle(codmat, denumire) {
document.getElementById('inputCodmat').value = codmat;
document.getElementById('selectedArticle').textContent = denumire;
document.getElementById('autocompleteDropdown').classList.add('d-none');
function clSelectArticle(el, codmat, label) {
const line = el.closest('.codmat-line');
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-selected').textContent = label;
line.querySelector('.cl-ac-dropdown').classList.add('d-none');
}
// Save mapping (create)
async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim();
const codmat = document.getElementById('inputCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
if (!sku) { alert('SKU este obligatoriu'); return; }
if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; }
const lines = document.querySelectorAll('.codmat-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.cl-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.cl-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; }
// Validate percentage for multi-line
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('pctWarning').style.display = '';
return;
}
}
document.getElementById('pctWarning').style.display = 'none';
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
});
const data = await res.json();
let res;
if (editingMapping) {
// Edit mode: use PUT /api/mappings/{old_sku}/{old_codmat}/edit
res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_sku: sku,
new_codmat: mappings[0].codmat,
cantitate_roa: mappings[0].cantitate_roa,
procent_pret: mappings[0].procent_pret
})
});
} else if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, 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, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
clearForm();
editingMapping = null;
loadMappings();
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
@@ -172,17 +369,117 @@ async function saveMapping() {
}
}
function clearForm() {
document.getElementById('inputSku').value = '';
document.getElementById('inputCodmat').value = '';
document.getElementById('inputCantitate').value = '1';
document.getElementById('inputProcent').value = '100';
document.getElementById('selectedArticle').textContent = '';
// ── Inline Add Row ──────────────────────────────
let inlineAddVisible = false;
function showInlineAddRow() {
if (inlineAddVisible) return;
inlineAddVisible = true;
const tbody = document.getElementById('mappingsBody');
const row = document.createElement('tr');
row.id = 'inlineAddRow';
row.className = 'table-info';
row.innerHTML = `
<td colspan="2">
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px">
</td>
<td colspan="2" class="position-relative">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
<small class="text-muted" id="inlineSelected"></small>
</td>
<td>-</td>
<td>
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px">
</td>
<td>
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
</td>
<td>-</td>
<td>
<button class="btn btn-sm btn-success me-1" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
</td>
`;
tbody.insertBefore(row, tbody.firstChild);
document.getElementById('inlineSku').focus();
// Setup autocomplete for inline CODMAT
const input = document.getElementById('inlineCodmat');
const dropdown = document.getElementById('inlineAcDropdown');
const selected = document.getElementById('inlineSelected');
let inlineAcTimeout = null;
input.addEventListener('input', () => {
clearTimeout(inlineAcTimeout);
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
// Inline edit
async function inlineAutocomplete(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="inlineSelectArticle('${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 inlineSelectArticle(codmat, label) {
document.getElementById('inlineCodmat').value = codmat;
document.getElementById('inlineSelected').textContent = label;
document.getElementById('inlineAcDropdown').classList.add('d-none');
}
async function saveInlineMapping() {
const sku = document.getElementById('inlineSku').value.trim();
const codmat = document.getElementById('inlineCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inlineProcent').value) || 100;
if (!sku) { alert('SKU este obligatoriu'); return; }
if (!codmat) { alert('CODMAT este obligatoriu'); return; }
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
});
const data = await res.json();
if (data.success) {
cancelInlineAdd();
loadMappings();
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
function cancelInlineAdd() {
const row = document.getElementById('inlineAddRow');
if (row) row.remove();
inlineAddVisible = false;
}
// ── Inline Edit ──────────────────────────────────
function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return; // Already editing
if (td.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
@@ -203,25 +500,18 @@ function editCell(td, sku, codmat, field, currentValue) {
td.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) {
loadMappings();
} else {
td.textContent = originalText;
alert('Eroare: ' + (data.error || 'Update failed'));
}
} catch (err) {
td.textContent = originalText;
}
if (data.success) { loadMappings(); }
else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { td.textContent = originalText; }
};
input.addEventListener('blur', save);
@@ -231,34 +521,100 @@ function editCell(td, sku, codmat, field, currentValue) {
});
}
// Toggle active
// ── Toggle Active with Toast Undo ────────────────
async function toggleActive(sku, codmat, currentActive) {
const newActive = currentActive ? 0 : 1;
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ activ: currentActive ? 0 : 1 })
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activ: newActive })
});
const data = await res.json();
if (!data.success) return;
loadMappings();
// Show toast with undo
const action = newActive ? 'activata' : 'dezactivata';
showUndoToast(`Mapare ${sku} \u2192 ${codmat} ${action}.`, () => {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activ: currentActive })
}).then(() => loadMappings());
});
} catch (err) { alert('Eroare: ' + err.message); }
}
function showUndoToast(message, undoCallback) {
document.getElementById('toastMessage').textContent = message;
const undoBtn = document.getElementById('toastUndoBtn');
// Clone to remove old listeners
const newBtn = undoBtn.cloneNode(true);
undoBtn.parentNode.replaceChild(newBtn, undoBtn);
newBtn.id = 'toastUndoBtn';
newBtn.addEventListener('click', () => {
undoCallback();
const toastEl = document.getElementById('undoToast');
const inst = bootstrap.Toast.getInstance(toastEl);
if (inst) inst.hide();
});
const toast = new bootstrap.Toast(document.getElementById('undoToast'));
toast.show();
}
// ── Delete with Modal Confirmation ──────────────
let pendingDelete = null;
function initDeleteModal() {
const btn = document.getElementById('confirmDeleteBtn');
if (!btn) return;
btn.addEventListener('click', async () => {
if (!pendingDelete) return;
const { sku, codmat } = pendingDelete;
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'DELETE'
});
const data = await res.json();
bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
if (data.success) loadMappings();
else alert('Eroare: ' + (data.error || 'Delete failed'));
} catch (err) {
bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
alert('Eroare: ' + err.message);
}
pendingDelete = null;
});
}
function deleteMappingConfirm(sku, codmat) {
pendingDelete = { sku, codmat };
document.getElementById('deleteSkuText').textContent = sku;
document.getElementById('deleteCodmatText').textContent = codmat;
new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show();
}
// ── Restore Deleted ──────────────────────────────
async function restoreMapping(sku, codmat) {
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, {
method: 'POST'
});
const data = await res.json();
if (data.success) loadMappings();
else alert('Eroare: ' + (data.error || 'Restore failed'));
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// Delete (soft)
function deleteMappingConfirm(sku, codmat) {
if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'DELETE'
}).then(r => r.json()).then(d => {
if (d.success) loadMappings();
else alert('Eroare: ' + (d.error || 'Delete failed'));
});
}
}
// ── CSV ──────────────────────────────────────────
// CSV import
async function importCsv() {
const fileInput = document.getElementById('csvFile');
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
@@ -267,12 +623,8 @@ async function importCsv() {
formData.append('file', fileInput.files[0]);
try {
const res = await fetch('/api/mappings/import-csv', {
method: 'POST',
body: formData
});
const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
const data = await res.json();
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
if (data.errors && data.errors.length > 0) {
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
@@ -284,15 +636,9 @@ async function importCsv() {
}
}
function exportCsv() {
window.location.href = '/api/mappings/export-csv';
}
function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
function downloadTemplate() {
window.location.href = '/api/mappings/csv-template';
}
// Escape HTML
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');