Files
gomag-vending/api/app/static/js/mappings.js
Marius Mutu 9c42187f02 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>
2026-03-11 14:35:16 +02:00

300 lines
10 KiB
JavaScript

let currentPage = 1;
let currentSearch = '';
let searchTimeout = null;
// Load on page ready
document.addEventListener('DOMContentLoaded', loadMappings);
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = document.getElementById('searchInput').value;
currentPage = 1;
loadMappings();
}, 300);
}
async function loadMappings() {
const params = new URLSearchParams({
search: currentSearch,
page: currentPage,
per_page: 50
});
try {
const res = await fetch(`/api/mappings?${params}`);
const data = await res.json();
renderTable(data.mappings);
renderPagination(data);
} catch (err) {
document.getElementById('mappingsBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
}
}
function renderTable(mappings) {
const tbody = document.getElementById('mappingsBody');
if (!mappings || mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
return;
}
tbody.innerHTML = mappings.map(m => `
<tr>
<td><strong>${esc(m.sku)}</strong></td>
<td><code>${esc(m.codmat)}</code></td>
<td>${esc(m.denumire || '-')}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})">${m.cantitate_roa}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})">${m.procent_pret}%</td>
<td>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" style="cursor:pointer"
onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})">
${m.activ ? 'Activ' : 'Inactiv'}
</span>
</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Dezactiveaza">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function renderPagination(data) {
const info = document.getElementById('pageInfo');
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
const ul = document.getElementById('pagination');
if (data.pages <= 1) { ul.innerHTML = ''; return; }
let html = '';
// Previous
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`;
// Pages (show max 7)
let start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 6);
for (let i = start; i <= end; i++) {
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
}
// Next
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">&raquo;</a></li>`;
ul.innerHTML = html;
}
function goPage(p) {
currentPage = p;
loadMappings();
}
// Autocomplete for CODMAT
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('inputCodmat');
if (!input) return;
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocomplete(input.value), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200);
});
});
async function autocomplete(q) {
const dropdown = document.getElementById('autocompleteDropdown');
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="selectArticle('${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 selectArticle(codmat, denumire) {
document.getElementById('inputCodmat').value = codmat;
document.getElementById('selectedArticle').textContent = denumire;
document.getElementById('autocompleteDropdown').classList.add('d-none');
}
// Save mapping (create)
async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim();
const codmat = document.getElementById('inputCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; }
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
clearForm();
loadMappings();
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
function clearForm() {
document.getElementById('inputSku').value = '';
document.getElementById('inputCodmat').value = '';
document.getElementById('inputCantitate').value = '1';
document.getElementById('inputProcent').value = '100';
document.getElementById('selectedArticle').textContent = '';
}
// Inline edit
function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return; // Already editing
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '80px';
const originalText = td.textContent;
td.textContent = '';
td.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
td.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) {
loadMappings();
} else {
td.textContent = originalText;
alert('Eroare: ' + (data.error || 'Update failed'));
}
} catch (err) {
td.textContent = originalText;
}
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { td.textContent = originalText; }
});
}
// Toggle active
async function toggleActive(sku, codmat, currentActive) {
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ activ: currentActive ? 0 : 1 })
});
const data = await res.json();
if (data.success) loadMappings();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// Delete (soft)
function deleteMappingConfirm(sku, codmat) {
if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'DELETE'
}).then(r => r.json()).then(d => {
if (d.success) loadMappings();
else alert('Eroare: ' + (d.error || 'Delete failed'));
});
}
}
// CSV import
async function importCsv() {
const fileInput = document.getElementById('csvFile');
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const res = await fetch('/api/mappings/import-csv', {
method: 'POST',
body: formData
});
const data = await res.json();
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
if (data.errors && data.errors.length > 0) {
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
}
document.getElementById('importResult').innerHTML = html;
loadMappings();
} catch (err) {
document.getElementById('importResult').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
function exportCsv() {
window.location.href = '/api/mappings/export-csv';
}
function downloadTemplate() {
window.location.href = '/api/mappings/csv-template';
}
// Escape HTML
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}