feat: add FastAPI admin dashboard with sync orchestration and test suite
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>
This commit is contained in:
57
api/app/templates/base.html
Normal file
57
api/app/templates/base.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_sync %}{% endblock %}" href="/sync">
|
||||
<i class="bi bi-arrow-repeat"></i> Import Comenzi
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
|
||||
<i class="bi bi-link-45deg"></i> Mapari SKU
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_missing %}{% endblock %}" href="/missing-skus">
|
||||
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<small class="text-muted">v1.0</small>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile toggle -->
|
||||
<button class="btn btn-dark d-md-none sidebar-toggle" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
135
api/app/templates/dashboard.html
Normal file
135
api/app/templates/dashboard.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - GoMag Import{% endblock %}
|
||||
{% block nav_dashboard %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Dashboard</h4>
|
||||
|
||||
<!-- Stat cards row -->
|
||||
<div class="row g-3 mb-4" id="statsRow">
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-secondary" id="stat-pending">-</div>
|
||||
<div class="stat-label">In Asteptare</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-primary" id="stat-ready">-</div>
|
||||
<div class="stat-label">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-success" id="stat-imported">-</div>
|
||||
<div class="stat-label">Imported</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-warning" id="stat-skipped">-</div>
|
||||
<div class="stat-label">Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-danger" id="stat-missing">-</div>
|
||||
<div class="stat-label">SKU Lipsa</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Control -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Sync Control</span>
|
||||
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
|
||||
<i class="bi bi-play-fill"></i> Start Sync
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="btnScan" onclick="scanOrders()">
|
||||
<i class="bi bi-search"></i> Scan
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
|
||||
<i class="bi bi-stop-fill"></i> Stop
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch d-inline-block me-2">
|
||||
<input class="form-check-input" type="checkbox" id="schedulerToggle" onchange="toggleScheduler()">
|
||||
<label class="form-check-label" for="schedulerToggle">Scheduler</label>
|
||||
</div>
|
||||
<select class="form-select form-select-sm d-inline-block" style="width:auto" id="schedulerInterval" onchange="updateSchedulerInterval()">
|
||||
<option value="1">1 min</option>
|
||||
<option value="5" selected>5 min</option>
|
||||
<option value="10">10 min</option>
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<small class="text-muted" id="syncProgressText"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sync Runs -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Ultimele Sync Runs</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>OK</th>
|
||||
<th>Skip</th>
|
||||
<th>Err</th>
|
||||
<th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="syncRunsBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing SKUs (quick resolve) -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>SKU-uri Lipsa</span>
|
||||
<a href="/missing-skus" class="btn btn-sm btn-outline-primary">Vezi toate</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Data</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missingSkusBody">
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
{% endblock %}
|
||||
118
api/app/templates/mappings.html
Normal file
118
api/app/templates/mappings.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mapari SKU - GoMag Import{% endblock %}
|
||||
{% block nav_mappings %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Mapari SKU</h4>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-plus-lg"></i> Adauga Mapare</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Cauta SKU, CODMAT sau denumire..." oninput="debounceSearch()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>CODMAT</th>
|
||||
<th>Denumire</th>
|
||||
<th>Cantitate ROA</th>
|
||||
<th>Procent Pret</th>
|
||||
<th>Activ</th>
|
||||
<th style="width:100px">Actiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mappingsBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="pageInfo"></small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div class="modal fade" id="addModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">SKU</label>
|
||||
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284">
|
||||
</div>
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control" id="inputCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="autocompleteDropdown"></div>
|
||||
<small class="text-muted" id="selectedArticle"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Cantitate ROA</label>
|
||||
<input type="number" class="form-control" id="inputCantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control" id="inputProcent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveMapping()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV Modal -->
|
||||
<div class="modal fade" id="importModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import CSV</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Format CSV: sku, codmat, cantitate_roa, procent_pret</p>
|
||||
<input type="file" class="form-control" id="csvFile" accept=".csv">
|
||||
<div id="importResult" class="mt-3"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||
<button type="button" class="btn btn-primary" onclick="importCsv()">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/mappings.js"></script>
|
||||
{% endblock %}
|
||||
223
api/app/templates/missing_skus.html
Normal file
223
api/app/templates/missing_skus.html
Normal file
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
||||
{% block nav_missing %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">SKU-uri Lipsa</h4>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
||||
<i class="bi bi-download"></i> Export CSV
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
|
||||
<i class="bi bi-search"></i> Re-Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>First Seen</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missingBody">
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted" id="missingInfo"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map SKU Modal -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label">CODMAT (Articol ROA)</label>
|
||||
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
||||
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div>
|
||||
<small class="text-muted" id="mapSelectedArticle"></small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Cantitate ROA</label>
|
||||
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<label class="form-label">Procent Pret (%)</label>
|
||||
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentMapSku = '';
|
||||
let acTimeout = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadMissing();
|
||||
|
||||
const input = document.getElementById('mapCodmat');
|
||||
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 loadMissing() {
|
||||
try {
|
||||
const res = await fetch('/api/validate/missing-skus');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingBody');
|
||||
|
||||
document.getElementById('missingInfo').textContent =
|
||||
`Total: ${data.total || 0} | Nerezolvate: ${data.unresolved || 0}`;
|
||||
|
||||
const skus = data.missing_skus || [];
|
||||
if (skus.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = skus.map(s => {
|
||||
const statusBadge = s.resolved
|
||||
? '<span class="badge bg-success">Rezolvat</span>'
|
||||
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
|
||||
|
||||
return `<tr class="${s.resolved ? 'table-light' : ''}">
|
||||
<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>${statusBadge}</td>
|
||||
<td>
|
||||
${!s.resolved ? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}')">
|
||||
<i class="bi bi-link-45deg"></i> Mapeaza
|
||||
</button>` : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('missingBody').innerHTML =
|
||||
`<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openMapModal(sku) {
|
||||
currentMapSku = sku;
|
||||
document.getElementById('mapSku').textContent = sku;
|
||||
document.getElementById('mapCodmat').value = '';
|
||||
document.getElementById('mapCantitate').value = '1';
|
||||
document.getElementById('mapProcent').value = '100';
|
||||
document.getElementById('mapSelectedArticle').textContent = '';
|
||||
new bootstrap.Modal(document.getElementById('mapModal')).show();
|
||||
}
|
||||
|
||||
async function autocompleteMap(q) {
|
||||
const dropdown = document.getElementById('mapAutocomplete');
|
||||
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();
|
||||
loadMissing();
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanForMissing() {
|
||||
try {
|
||||
await fetch('/api/validate/scan', { method: 'POST' });
|
||||
loadMissing();
|
||||
} catch (err) {
|
||||
alert('Eroare scan: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function exportMissingCsv() {
|
||||
window.location.href = '/api/validate/missing-skus-csv';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
158
api/app/templates/sync_detail.html
Normal file
158
api/app/templates/sync_detail.html
Normal file
@@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sync Run - GoMag Import{% endblock %}
|
||||
{% block nav_sync %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="/" class="text-decoration-none text-muted"><i class="bi bi-arrow-left"></i> Dashboard</a>
|
||||
<h4 class="mb-0 mt-1">Sync Run <small class="text-muted" id="runId">{{ run_id }}</small></h4>
|
||||
</div>
|
||||
<span class="badge bg-secondary fs-6" id="runStatusBadge">-</span>
|
||||
</div>
|
||||
|
||||
<!-- Run summary -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value" id="runTotal">-</div>
|
||||
<div class="stat-label">Total Comenzi</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-success" id="runImported">-</div>
|
||||
<div class="stat-label">Imported</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-warning" id="runSkipped">-</div>
|
||||
<div class="stat-label">Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-danger" id="runErrors">-</div>
|
||||
<div class="stat-label">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted" id="runTiming"></small>
|
||||
</div>
|
||||
|
||||
<!-- Orders table -->
|
||||
<div class="card">
|
||||
<div class="card-header">Comenzi</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Nr Comanda</th>
|
||||
<th>Data</th>
|
||||
<th>Client</th>
|
||||
<th>Articole</th>
|
||||
<th>Status</th>
|
||||
<th>Detalii</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ordersBody">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const RUN_ID = '{{ run_id }}';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadRunDetail);
|
||||
|
||||
async function loadRunDetail() {
|
||||
try {
|
||||
const res = await fetch(`/api/sync/run/${RUN_ID}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('ordersBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">${data.error}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const run = data.run;
|
||||
|
||||
// Update summary
|
||||
document.getElementById('runTotal').textContent = run.total_orders || 0;
|
||||
document.getElementById('runImported').textContent = run.imported || 0;
|
||||
document.getElementById('runSkipped').textContent = run.skipped || 0;
|
||||
document.getElementById('runErrors').textContent = run.errors || 0;
|
||||
|
||||
const badge = document.getElementById('runStatusBadge');
|
||||
badge.textContent = run.status;
|
||||
badge.className = 'badge fs-6 ' + (run.status === 'completed' ? 'bg-success' : run.status === 'running' ? 'bg-primary' : 'bg-danger');
|
||||
|
||||
// Timing
|
||||
if (run.started_at) {
|
||||
let timing = 'Start: ' + new Date(run.started_at).toLocaleString('ro-RO');
|
||||
if (run.finished_at) {
|
||||
const sec = Math.round((new Date(run.finished_at) - new Date(run.started_at)) / 1000);
|
||||
timing += ` | Durata: ${sec < 60 ? sec + 's' : Math.floor(sec/60) + 'm ' + (sec%60) + 's'}`;
|
||||
}
|
||||
document.getElementById('runTiming').textContent = timing;
|
||||
}
|
||||
|
||||
// Orders table
|
||||
const orders = data.orders || [];
|
||||
if (orders.length === 0) {
|
||||
document.getElementById('ordersBody').innerHTML =
|
||||
'<tr><td colspan="7" class="text-center text-muted py-4">Nicio comanda</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('ordersBody').innerHTML = orders.map((o, i) => {
|
||||
const statusClass = o.status === 'IMPORTED' ? 'badge-imported' : o.status === 'SKIPPED' ? 'badge-skipped' : 'badge-error';
|
||||
|
||||
let details = '';
|
||||
if (o.status === 'IMPORTED' && o.id_comanda) {
|
||||
details = `<small class="text-success">ID: ${o.id_comanda}</small>`;
|
||||
} else if (o.status === 'SKIPPED' && o.missing_skus) {
|
||||
try {
|
||||
const skus = JSON.parse(o.missing_skus);
|
||||
details = `<small class="text-warning">SKU lipsa: ${skus.map(s => '<code>' + esc(s) + '</code>').join(', ')}</small>`;
|
||||
} catch(e) {
|
||||
details = `<small class="text-warning">${esc(o.missing_skus)}</small>`;
|
||||
}
|
||||
} else if (o.status === 'ERROR' && o.error_message) {
|
||||
details = `<small class="text-danger">${esc(o.error_message).substring(0, 100)}</small>`;
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td><strong>${esc(o.order_number)}</strong></td>
|
||||
<td><small>${o.order_date ? o.order_date.substring(0, 10) : '-'}</small></td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || '-'}</td>
|
||||
<td><span class="badge ${statusClass}">${o.status}</span></td>
|
||||
<td>${details}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('ordersBody').innerHTML =
|
||||
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user