Replace Flask admin with FastAPI app (api/app/) featuring: - Dashboard with stat cards, sync control, and history - Mappings CRUD for ARTICOLE_TERTI with CSV import/export - Article autocomplete from NOM_ARTICOLE - SKU pre-validation before import - Sync orchestration: read JSONs -> validate -> import -> log to SQLite - APScheduler for periodic sync from UI - File logging to logs/sync_comenzi_YYYYMMDD_HHMMSS.log - Oracle pool None guard (503 vs 500 on unavailable) Test suite: - test_app_basic.py: 30 tests (imports + routes) without Oracle - test_integration.py: 9 integration tests with Oracle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
7.8 KiB
JavaScript
216 lines
7.8 KiB
JavaScript
let refreshInterval = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadDashboard();
|
|
// Auto-refresh every 10 seconds
|
|
refreshInterval = setInterval(loadDashboard, 10000);
|
|
});
|
|
|
|
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();
|
|
|
|
// 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;
|
|
|
|
// 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} skip, ${lr.errors || 0} err`;
|
|
} 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='/sync/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');
|
|
const data = await res.json();
|
|
const tbody = document.getElementById('missingSkusBody');
|
|
|
|
// Update stat card
|
|
document.getElementById('stat-missing').textContent = data.unresolved || 0;
|
|
|
|
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>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = unresolved.slice(0, 10).map(s => `
|
|
<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>
|
|
<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>
|
|
</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);
|
|
}
|
|
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();
|
|
|
|
// Update pending/ready stats
|
|
document.getElementById('stat-pending').textContent = data.total_orders || 0;
|
|
document.getElementById('stat-ready').textContent = data.importable || 0;
|
|
|
|
let msg = `Scan complet: ${data.total_orders || 0} comenzi, ${data.importable || 0} ready, ${data.skipped || 0} skipped`;
|
|
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();
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|