feat(dashboard): add logs page, pagination, quick mapping modal, price pre-validation
- 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>
This commit is contained in:
@@ -1,9 +1,22 @@
|
||||
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() {
|
||||
@@ -20,11 +33,36 @@ async function loadSyncStatus() {
|
||||
const res = await fetch('/api/sync/status');
|
||||
const data = await res.json();
|
||||
|
||||
// Update stats
|
||||
const stats = data.stats || {};
|
||||
document.getElementById('stat-imported').textContent = stats.imported || 0;
|
||||
document.getElementById('stat-skipped').textContent = stats.skipped || 0;
|
||||
document.getElementById('stat-missing').textContent = stats.missing_skus || 0;
|
||||
|
||||
// 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');
|
||||
@@ -46,7 +84,7 @@ async function loadSyncStatus() {
|
||||
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} skip, ${lr.errors || 0} err`;
|
||||
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} fara mapare, ${lr.errors || 0} erori`;
|
||||
} else {
|
||||
document.getElementById('syncProgressText').textContent = '';
|
||||
}
|
||||
@@ -93,32 +131,42 @@ async function loadSyncHistory() {
|
||||
|
||||
async function loadMissingSkus() {
|
||||
try {
|
||||
const res = await fetch('/api/validate/missing-skus');
|
||||
const res = await fetch('/api/validate/missing-skus?page=1&per_page=10');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingSkusBody');
|
||||
|
||||
// Update stat card
|
||||
document.getElementById('stat-missing').textContent = data.unresolved || 0;
|
||||
// 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="4" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
|
||||
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 => `
|
||||
<tr>
|
||||
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><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
|
||||
<td>${s.order_count != null ? s.order_count : '-'}</td>
|
||||
<td><small>${esc(firstCustomer)}</small></td>
|
||||
<td>
|
||||
<a href="/mappings?sku=${encodeURIComponent(s.sku)}" class="btn btn-sm btn-outline-primary" title="Creeaza mapare">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</a>
|
||||
<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('');
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('loadMissingSkus error:', err);
|
||||
}
|
||||
@@ -169,11 +217,23 @@ async function scanOrders() {
|
||||
const res = await fetch('/api/validate/scan', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
// Update pending/ready stats
|
||||
document.getElementById('stat-pending').textContent = data.total_orders || 0;
|
||||
document.getElementById('stat-ready').textContent = data.importable || 0;
|
||||
// Persist scan results so auto-refresh doesn't overwrite them
|
||||
saveScanData(data);
|
||||
|
||||
let msg = `Scan complet: ${data.total_orders || 0} comenzi, ${data.importable || 0} ready, ${data.skipped || 0} skipped`;
|
||||
// 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`;
|
||||
}
|
||||
@@ -209,6 +269,106 @@ 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; }
|
||||
|
||||
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, ''');
|
||||
|
||||
216
api/app/static/js/logs.js
Normal file
216
api/app/static/js/logs.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user