- Add SSE event bus in sync_service (subscribe/unsubscribe/_emit) - Add GET /api/sync/stream SSE endpoint for real-time sync progress - Rewrite logs.html: unified runs table + live feed + summary + filters - Rewrite logs.js: SSE EventSource client, run selection, pagination - Dashboard: clickable runs navigate to /logs?run=, sync started banner - Remove "Import Comenzi" nav item, delete sync_detail.html - Add error_message column to sync_runs table with migration - Fix: export TNS_ADMIN as OS env var so oracledb finds tnsnames.ora - Fix: use get_oracle_connection() instead of direct pool.acquire() - Fix: CRM_POLITICI_PRET_ART INSERT to match actual table schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
386 lines
15 KiB
JavaScript
386 lines
15 KiB
JavaScript
let refreshInterval = null;
|
|
let currentMapSku = '';
|
|
let acTimeout = 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);
|
|
});
|
|
}
|
|
});
|
|
|
|
async function loadDashboard() {
|
|
await Promise.all([
|
|
loadSyncStatus(),
|
|
loadSyncHistory(),
|
|
loadMissingSkus(),
|
|
loadSchedulerStatus()
|
|
]);
|
|
}
|
|
|
|
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');
|
|
document.getElementById('syncProgressText').textContent = data.progress || 'Running...';
|
|
} else {
|
|
document.getElementById('btnStartSync').classList.remove('d-none');
|
|
document.getElementById('btnStopSync').classList.add('d-none');
|
|
|
|
// Show last run info
|
|
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`;
|
|
} else {
|
|
document.getElementById('syncProgressText').textContent = '';
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('loadSyncStatus error:', err);
|
|
}
|
|
}
|
|
|
|
async function loadSyncHistory() {
|
|
try {
|
|
const res = await fetch('/api/sync/history?per_page=10');
|
|
const data = await res.json();
|
|
const tbody = document.getElementById('syncRunsBody');
|
|
|
|
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>';
|
|
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';
|
|
|
|
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('');
|
|
} catch (err) {
|
|
console.error('loadSyncHistory 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');
|
|
|
|
// Update article-level stat card (unresolved count)
|
|
if (data.total != null) {
|
|
document.getElementById('stat-missing-skus').textContent = data.total;
|
|
}
|
|
|
|
const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
|
|
|
|
if (unresolved.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
|
|
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 */ }
|
|
|
|
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>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
console.error('loadMissingSkus error:', err);
|
|
}
|
|
}
|
|
|
|
async function loadSchedulerStatus() {
|
|
try {
|
|
const res = await fetch('/api/sync/schedule');
|
|
const data = await res.json();
|
|
|
|
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
|
if (data.interval_minutes) {
|
|
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
|
}
|
|
} catch (err) {
|
|
console.error('loadSchedulerStatus error:', err);
|
|
}
|
|
}
|
|
|
|
async function startSync() {
|
|
try {
|
|
const res = await fetch('/api/sync/start', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
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');
|
|
if (banner && link) {
|
|
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
|
|
banner.classList.remove('d-none');
|
|
}
|
|
}
|
|
loadDashboard();
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function stopSync() {
|
|
try {
|
|
await fetch('/api/sync/stop', { method: 'POST' });
|
|
loadDashboard();
|
|
} 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;
|
|
|
|
try {
|
|
await fetch('/api/sync/schedule', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled, interval_minutes: interval })
|
|
});
|
|
} catch (err) {
|
|
alert('Eroare scheduler: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function updateSchedulerInterval() {
|
|
const enabled = document.getElementById('schedulerToggle').checked;
|
|
if (enabled) {
|
|
await toggleScheduler();
|
|
}
|
|
}
|
|
|
|
// --- 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; }
|
|
|
|
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="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'));
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// --- 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, ''');
|
|
}
|