- Add /logs page with per-order sync run details, filters (Toate/Importate/Fara Mapare/Erori) - Add price pre-validation (validate_prices + ensure_prices) to prevent ORA-20000 on direct articles - Add find_new_orders() to detect orders not yet in Oracle COMENZI - Extend missing_skus table with order context (order_count, order_numbers, customers) - Add server-side pagination on /api/validate/missing-skus and /missing-skus page - Replace confusing "Skip"/"Err" with "Fara Mapare"/"Erori" terminology - Add inline mapping modal on dashboard (replaces navigation to /mappings) - Add 2-row stat cards: orders (Comenzi Noi/Ready/Importate/Fara Mapare/Erori) + articles - Add ID_POL/ID_GESTIUNE/ID_SECTIE to config.py and .env - Update .gitignore (venv, *.db, api/api/, logs/) - 33/33 unit tests pass, E2E verified with Playwright Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
8.2 KiB
JavaScript
217 lines
8.2 KiB
JavaScript
// logs.js - Jurnale Import page logic
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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);
|
|
if (isNaN(diffMs) || diffMs < 0) return '-';
|
|
const secs = Math.round(diffMs / 1000);
|
|
if (secs < 60) return secs + 's';
|
|
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 runStatusBadge(status) {
|
|
switch ((status || '').toUpperCase()) {
|
|
case 'SUCCESS': return '<span class="badge bg-success ms-1">SUCCESS</span>';
|
|
case 'ERROR': return '<span class="badge bg-danger ms-1">ERROR</span>';
|
|
case 'RUNNING': return '<span class="badge bg-primary ms-1">RUNNING</span>';
|
|
case 'PARTIAL': return '<span class="badge bg-warning text-dark ms-1">PARTIAL</span>';
|
|
default: return `<span class="badge bg-secondary ms-1">${esc(status || '')}</span>`;
|
|
}
|
|
}
|
|
|
|
async function loadRuns() {
|
|
const sel = document.getElementById('runSelector');
|
|
sel.innerHTML = '<option value="">Se incarca...</option>';
|
|
|
|
try {
|
|
const res = await fetch('/api/sync/history?per_page=20');
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
const data = await res.json();
|
|
|
|
const runs = data.runs || [];
|
|
if (runs.length === 0) {
|
|
sel.innerHTML = '<option value="">Nu exista sync runs</option>';
|
|
return;
|
|
}
|
|
|
|
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`;
|
|
const statusText = (r.status || '').toUpperCase();
|
|
return `<option value="${esc(r.run_id)}">[${statusText}] ${date} — ${stats}</option>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
sel.innerHTML = '<option value="">Eroare la incarcare: ' + esc(err.message) + '</option>';
|
|
}
|
|
}
|
|
|
|
async function loadRunLog(runId) {
|
|
const tbody = document.getElementById('logsBody');
|
|
const filterRow = document.getElementById('filterRow');
|
|
const runSummary = document.getElementById('runSummary');
|
|
|
|
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>';
|
|
filterRow.style.display = 'none';
|
|
runSummary.style.display = 'none';
|
|
|
|
try {
|
|
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
const data = await res.json();
|
|
|
|
const run = data.run || {};
|
|
const orders = data.orders || [];
|
|
|
|
// 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);
|
|
runSummary.style.display = '';
|
|
|
|
if (orders.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
|
|
filterRow.style.display = 'none';
|
|
updateFilterCount();
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = orders.map(order => {
|
|
const status = (order.status || '').toUpperCase();
|
|
|
|
// Parse missing_skus — API returns JSON string or null
|
|
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) {
|
|
// malformed JSON — skip
|
|
}
|
|
}
|
|
|
|
const details = order.error_message
|
|
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
|
|
: missingSkuTags || '<span class="text-muted">-</span>';
|
|
|
|
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('');
|
|
|
|
filterRow.style.display = '';
|
|
// Reset filter to "Toate"
|
|
document.querySelectorAll('[data-filter]').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
|
});
|
|
applyFilter('all');
|
|
|
|
} 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>`;
|
|
filterRow.style.display = 'none';
|
|
runSummary.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
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 updateFilterCount(visible, total, filter) {
|
|
const el = document.getElementById('filterCount');
|
|
if (!el) return;
|
|
if (visible == null) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
if (filter === 'all') {
|
|
el.textContent = `${total} comenzi`;
|
|
} else {
|
|
el.textContent = `${visible} din ${total} comenzi`;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadRuns();
|
|
|
|
// Dropdown change
|
|
document.getElementById('runSelector').addEventListener('change', function () {
|
|
const runId = this.value;
|
|
if (!runId) {
|
|
document.getElementById('logsBody').innerHTML = `<tr id="emptyState">
|
|
<td colspan="5" class="text-center text-muted py-5">
|
|
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
|
|
Selecteaza un sync run din lista de sus
|
|
</td>
|
|
</tr>`;
|
|
document.getElementById('filterRow').style.display = 'none';
|
|
document.getElementById('runSummary').style.display = 'none';
|
|
return;
|
|
}
|
|
loadRunLog(runId);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
});
|