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:
@@ -35,6 +35,11 @@
|
||||
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_logs %}{% endblock %}" href="/logs">
|
||||
<i class="bi bi-journal-text"></i> Jurnale Import
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<small class="text-muted">v1.0</small>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Dashboard</h4>
|
||||
|
||||
<!-- Stat cards row -->
|
||||
<div class="row g-3 mb-4" id="statsRow">
|
||||
<!-- Stat cards - Row 1: Comenzi -->
|
||||
<div class="row g-3 mb-2" 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 class="stat-value text-info" id="stat-new">-</div>
|
||||
<div class="stat-label">Comenzi Noi</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
@@ -22,28 +22,57 @@
|
||||
<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 class="stat-label">Importate</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 class="stat-label">Fără Mapare</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 class="stat-value text-danger" id="stat-errors">-</div>
|
||||
<div class="stat-label">Erori Import</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards - Row 2: Articole -->
|
||||
<div class="row g-3 mb-4" id="statsRowArticles">
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-secondary" id="stat-total-skus">-</div>
|
||||
<div class="stat-label">Total SKU Scanate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-success" id="stat-mapped-skus">-</div>
|
||||
<div class="stat-label">Cu Mapare</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-value text-warning" id="stat-missing-skus">-</div>
|
||||
<div class="stat-label">Fără Mapare</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col d-none d-md-block"></div>
|
||||
<div class="col d-none d-md-block"></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 class="d-flex align-items-center gap-2">
|
||||
<a href="/logs" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-journal-text"></i> Jurnale Import
|
||||
</a>
|
||||
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
@@ -91,8 +120,8 @@
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>OK</th>
|
||||
<th>Skip</th>
|
||||
<th>Err</th>
|
||||
<th>Fără mapare</th>
|
||||
<th>Erori</th>
|
||||
<th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -117,17 +146,52 @@
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Data</th>
|
||||
<th>Actiune</th>
|
||||
<th>Nr. Comenzi</th>
|
||||
<th>Primul Client</th>
|
||||
<th colspan="2">Acțiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missingSkusBody">
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
<tr><td colspan="5" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map SKU Modal (copied from missing_skus.html) -->
|
||||
<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 %}
|
||||
|
||||
101
api/app/templates/logs.html
Normal file
101
api/app/templates/logs.html
Normal file
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jurnale Import - GoMag Import{% endblock %}
|
||||
{% block nav_logs %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Jurnale Import</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select class="form-select form-select-sm" id="runSelector" style="min-width: 320px;">
|
||||
<option value="">-- Selecteaza un sync run --</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca lista">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter buttons -->
|
||||
<div class="mb-3" id="filterRow" style="display:none;">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
|
||||
<i class="bi bi-list-ul"></i> Toate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
|
||||
<i class="bi bi-check-circle"></i> Importate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
|
||||
<i class="bi bi-skip-forward"></i> Fara Mapare
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
|
||||
<i class="bi bi-x-circle"></i> Erori
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted ms-3" id="filterCount"></small>
|
||||
</div>
|
||||
|
||||
<!-- Run summary bar -->
|
||||
<div class="row g-3 mb-3" id="runSummary" style="display:none;">
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Importate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Omise</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Erori</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card stat-card px-3 py-2">
|
||||
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
|
||||
<div class="stat-label">Durata</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders table -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="logsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:140px;">Nr. Comanda</th>
|
||||
<th>Client</th>
|
||||
<th style="width:100px;" class="text-center">Nr. Articole</th>
|
||||
<th style="width:120px;">Status</th>
|
||||
<th>Eroare / Detalii</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody">
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/logs.js"></script>
|
||||
{% endblock %}
|
||||
@@ -23,13 +23,15 @@
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Nr. Comenzi</th>
|
||||
<th>Client</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>
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -39,6 +41,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="paginationNav" class="mt-3">
|
||||
<ul class="pagination justify-content-center" id="paginationControls"></ul>
|
||||
</nav>
|
||||
|
||||
<!-- Map SKU Modal -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@@ -78,9 +84,11 @@
|
||||
<script>
|
||||
let currentMapSku = '';
|
||||
let acTimeout = null;
|
||||
let currentPage = 1;
|
||||
const perPage = 20;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadMissing();
|
||||
loadMissing(1);
|
||||
|
||||
const input = document.getElementById('mapCodmat');
|
||||
input.addEventListener('input', () => {
|
||||
@@ -92,18 +100,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function loadMissing() {
|
||||
async function loadMissing(page) {
|
||||
currentPage = page || 1;
|
||||
try {
|
||||
const res = await fetch('/api/validate/missing-skus');
|
||||
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}`);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('missingBody');
|
||||
|
||||
document.getElementById('missingInfo').textContent =
|
||||
`Total: ${data.total || 0} | Nerezolvate: ${data.unresolved || 0}`;
|
||||
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
|
||||
|
||||
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>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
|
||||
renderPagination(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,31 +122,85 @@ async function loadMissing() {
|
||||
? '<span class="badge bg-success">Rezolvat</span>'
|
||||
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
|
||||
|
||||
let firstCustomer = '-';
|
||||
try {
|
||||
const customers = JSON.parse(s.customers || '[]');
|
||||
if (customers.length > 0) firstCustomer = customers[0];
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
|
||||
const orderCount = s.order_count != null ? s.order_count : '-';
|
||||
|
||||
return `<tr class="${s.resolved ? 'table-light' : ''}">
|
||||
<td><code>${esc(s.sku)}</code></td>
|
||||
<td>${esc(s.product_name || '-')}</td>
|
||||
<td>${esc(orderCount)}</td>
|
||||
<td><small>${esc(firstCustomer)}</small></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>`}
|
||||
${!s.resolved
|
||||
? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')">
|
||||
<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('');
|
||||
|
||||
renderPagination(data);
|
||||
} catch (err) {
|
||||
document.getElementById('missingBody').innerHTML =
|
||||
`<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
|
||||
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openMapModal(sku) {
|
||||
function renderPagination(data) {
|
||||
const ul = document.getElementById('paginationControls');
|
||||
const total = data.pages || 1;
|
||||
const page = data.page || 1;
|
||||
|
||||
if (total <= 1) {
|
||||
ul.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a>
|
||||
</li>`;
|
||||
|
||||
const range = 2;
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
|
||||
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a>
|
||||
</li>`;
|
||||
} else if (i === page - range - 1 || i === page + range + 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a>
|
||||
</li>`;
|
||||
|
||||
ul.innerHTML = html;
|
||||
}
|
||||
|
||||
function openMapModal(sku, productName) {
|
||||
currentMapSku = sku;
|
||||
document.getElementById('mapSku').textContent = sku;
|
||||
document.getElementById('mapCodmat').value = '';
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -193,7 +257,7 @@ async function saveQuickMap() {
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
|
||||
loadMissing();
|
||||
loadMissing(currentPage);
|
||||
} else {
|
||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
@@ -205,7 +269,7 @@ async function saveQuickMap() {
|
||||
async function scanForMissing() {
|
||||
try {
|
||||
await fetch('/api/validate/scan', { method: 'POST' });
|
||||
loadMissing();
|
||||
loadMissing(1);
|
||||
} catch (err) {
|
||||
alert('Eroare scan: ' + err.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user