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:
214
api/app/static/css/style.css
Normal file
214
api/app/static/css/style.css
Normal file
@@ -0,0 +1,214 @@
|
||||
:root {
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-bg: #1e293b;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-active: #ffffff;
|
||||
--sidebar-hover-bg: #334155;
|
||||
--body-bg: #f1f5f9;
|
||||
--card-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--body-bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background-color: var(--sidebar-bg);
|
||||
padding: 0;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.sidebar-header h5 {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: var(--sidebar-text);
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: var(--sidebar-active);
|
||||
background-color: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: var(--sidebar-active);
|
||||
background-color: var(--sidebar-hover-bg);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
margin-right: 0.5rem;
|
||||
width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 1.5rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar toggle button for mobile */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 1100;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-imported { background-color: #22c55e; }
|
||||
.badge-skipped { background-color: #eab308; color: #000; }
|
||||
.badge-error { background-color: #ef4444; }
|
||||
.badge-pending { background-color: #94a3b8; }
|
||||
.badge-ready { background-color: #3b82f6; }
|
||||
|
||||
/* Stat cards */
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
.sidebar-toggle {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Autocomplete dropdown */
|
||||
.autocomplete-dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.autocomplete-item:hover, .autocomplete-item.active {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.autocomplete-item .codmat {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.autocomplete-item .denumire {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination .page-link {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
215
api/app/static/js/dashboard.js
Normal file
215
api/app/static/js/dashboard.js
Normal file
@@ -0,0 +1,215 @@
|
||||
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, ''');
|
||||
}
|
||||
299
api/app/static/js/mappings.js
Normal file
299
api/app/static/js/mappings.js
Normal file
@@ -0,0 +1,299 @@
|
||||
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;">«</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;">»</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user