Compare commits

...

12 Commits

Author SHA1 Message Date
Claude Agent
f68adbb072 chore: bump CSS cache version to v=17 2026-03-19 23:29:25 +00:00
Claude Agent
eccd9dd753 style(design): FINDING-008 — add color-scheme: light declaration 2026-03-19 23:29:17 +00:00
Claude Agent
73fe53394e style(design): FINDING-007 — add text-wrap: balance to headings 2026-03-19 23:29:09 +00:00
Claude Agent
039cbb1438 style(design): FINDING-005 — increase filter pill padding for 44px touch target 2026-03-19 23:28:48 +00:00
Claude Agent
1353d4b8cf style(design): FINDING-004 — add tabular-nums to table cells for aligned numbers 2026-03-19 23:28:39 +00:00
Claude Agent
f1c7625ec7 style(design): FINDING-003 — add focus ring to search input, remove outline:none 2026-03-19 23:28:30 +00:00
Claude Agent
a898666869 style(design): FINDING-002 — increase checkbox size from 13px to 18px 2026-03-19 23:28:08 +00:00
Claude Agent
1cea8cace0 style(design): FINDING-001 — increase pagination button size to 44px touch target 2026-03-19 23:27:56 +00:00
Claude Agent
327f0e6ea2 refactor(ui): unify mapping form into single shared component
Extract the SKU mapping modal (HTML + JS) from dashboard, logs, and
missing_skus into a shared component in base.html + shared.js. All pages
now use the same compact layout with CODMAT/Cant. column headers.

- Fix missing_skus backdrop bug: event.stopPropagation() on icon click
  prevents double modal open from <a> + <tr> event bubbling
- Shrink mappings addModal from modal-lg to regular size with compact layout
- Remove ~500 lines of duplicated modal HTML and JS across 4 pages
- Each page keeps a thin wrapper (openDashQuickMap, openLogsQuickMap,
  openMapModal) that calls shared openQuickMap() with an onSave callback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:21:43 +00:00
Claude Agent
c806ca2d81 fix(ui): format price sync timestamps as dd.mm.yyyy hh24:mi:ss Bucharest time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:55:38 +00:00
Claude Agent
952989d34b fix: remove procent_pret from quick-map modals, fix catalog price sync
Remove leftover procent_pret input fields and validation from dashboard,
logs and missing_skus quick-map modals (missed in 9e5901a). Fix GoMag
Products API returning dict-keyed products instead of array, which caused
catalog price sync to find 0 products with SKU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:53:36 +00:00
Claude Agent
aa6e035c02 fix(oracle): use na.cont instead of na.cont_vanzare in kit pricing
The column cont_vanzare does not exist in nom_articole. The correct
column name is cont, consistent with all Python code references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:32:20 +00:00
14 changed files with 287 additions and 559 deletions

View File

@@ -34,7 +34,7 @@ python api/test_integration.py # cu Oracle
1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle 1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache** 2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache**
3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct) 3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct)
4. Complex sets: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sa fie sum=100%) 4. Complex sets (kituri/pachete): un SKU → multiple CODMAT-uri cu `cantitate_roa`; preturile se preiau din lista de preturi Oracle
5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle 5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle
### Statusuri comenzi ### Statusuri comenzi
@@ -51,6 +51,11 @@ python api/test_integration.py # cu Oracle
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie) - Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
- Daca pretul lipseste, se insereaza automat pret=0 - Daca pretul lipseste, se insereaza automat pret=0
### Dashboard paginare
- Contorul din paginare arata **totalul comenzilor** din perioada selectata (ex: "378 comenzi"), NU doar cele filtrate
- Butoanele de filtru (Importat, Omise, Erori, Facturate, Nefacturate, Anulate) arata fiecare cate comenzi are pe langa total
- Aceasta este comportamentul dorit: userul vede cate comenzi totale sunt, din care cate importate, cu erori etc.
### Invoice cache ### Invoice cache
- Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`) - Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`)
- Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA - Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA

View File

@@ -157,7 +157,12 @@ async def download_products(
products = data.get("products", []) products = data.get("products", [])
if isinstance(products, dict): if isinstance(products, dict):
products = [products] # GoMag returns products as {"1": {...}, "2": {...}} dict
first_val = next(iter(products.values()), None) if products else None
if isinstance(first_val, dict):
products = list(products.values())
else:
products = [products]
if isinstance(products, list): if isinstance(products, list):
for p in products: for p in products:
if isinstance(p, dict) and p.get("sku"): if isinstance(p, dict) and p.get("sku"):

View File

@@ -35,7 +35,7 @@ async def prepare_price_sync() -> dict:
try: try:
await db.execute( await db.execute(
"INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')", "INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')",
(run_id, _now().isoformat()) (run_id, _now().strftime("%d.%m.%Y %H:%M:%S"))
) )
await db.commit() await db.commit()
finally: finally:
@@ -212,7 +212,7 @@ async def _finish_run(run_id, status, log_lines, products_total=0,
matched = ?, updated = ?, errors = ?, matched = ?, updated = ?, errors = ?,
log_text = ? log_text = ?
WHERE run_id = ? WHERE run_id = ?
""", (_now().isoformat(), status, products_total, matched, updated, errors, """, (_now().strftime("%d.%m.%Y %H:%M:%S"), status, products_total, matched, updated, errors,
"\n".join(log_lines), run_id)) "\n".join(log_lines), run_id))
await db.commit() await db.commit()
finally: finally:

View File

@@ -35,6 +35,18 @@ body {
padding: 0; padding: 0;
} }
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
/* ── Checkboxes — accessible size ────────────────── */
input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--blue-600);
cursor: pointer;
}
/* ── Top Navbar ──────────────────────────────────── */ /* ── Top Navbar ──────────────────────────────────── */
.top-navbar { .top-navbar {
position: fixed; position: fixed;
@@ -141,6 +153,7 @@ body {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1rem; font-size: 1rem;
font-variant-numeric: tabular-nums;
} }
/* Zebra striping */ /* Zebra striping */
@@ -212,10 +225,10 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 2rem; min-width: 2.75rem;
height: 2rem; height: 2.75rem;
padding: 0 0.5rem; padding: 0 0.5rem;
font-size: 0.8125rem; font-size: 0.875rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
@@ -356,11 +369,16 @@ body {
.qm-row { display: flex; gap: 6px; align-items: center; } .qm-row { display: flex; gap: 6px; align-items: center; }
.qm-codmat-wrap { flex: 1; min-width: 0; } .qm-codmat-wrap { flex: 1; min-width: 0; }
.qm-rm-btn { padding: 2px 6px; line-height: 1; } .qm-rm-btn { padding: 2px 6px; line-height: 1; }
#qmCodmatLines .qm-selected:empty { display: none; } #qmCodmatLines .qm-selected:empty,
#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; } #codmatLines .qm-selected:empty { display: none; }
#quickMapModal .modal-header { padding: 10px 16px; } #quickMapModal .modal-body,
#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; } #addModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
#quickMapModal .modal-footer { padding: 8px 16px; } #quickMapModal .modal-header,
#addModal .modal-header { padding: 10px 16px; }
#quickMapModal .modal-header h5,
#addModal .modal-header h5 { font-size: 0.95rem; margin: 0; }
#quickMapModal .modal-footer,
#addModal .modal-footer { padding: 8px 16px; }
/* ── Deleted mapping rows ────────────────────────── */ /* ── Deleted mapping rows ────────────────────────── */
tr.mapping-deleted td { tr.mapping-deleted td {
@@ -399,7 +417,7 @@ tr.mapping-deleted td {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;
padding: 0.375rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
@@ -429,10 +447,12 @@ tr.mapping-deleted td {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.9375rem; font-size: 0.9375rem;
outline: none;
width: 160px; width: 160px;
} }
.search-input:focus { border-color: var(--blue-600); } .search-input:focus {
border-color: var(--blue-600);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* ── Autocomplete dropdown (keep as-is) ──────────── */ /* ── Autocomplete dropdown (keep as-is) ──────────── */
.autocomplete-dropdown { .autocomplete-dropdown {

View File

@@ -4,10 +4,6 @@ let dashPerPage = 50;
let dashSortCol = 'order_date'; let dashSortCol = 'order_date';
let dashSortDir = 'desc'; let dashSortDir = 'desc';
let dashSearchTimeout = null; let dashSearchTimeout = null;
let currentQmSku = '';
let currentQmOrderNumber = '';
let qmAcTimeout = null;
// Sync polling state // Sync polling state
let _pollInterval = null; let _pollInterval = null;
let _lastSyncStatus = null; let _lastSyncStatus = null;
@@ -484,7 +480,7 @@ function renderCodmatCell(item) {
return `<code>${esc(d.codmat)}</code>`; return `<code>${esc(d.codmat)}</code>`;
} }
return item.codmat_details.map(d => return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>` `<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join(''); ).join('');
} }
@@ -591,7 +587,7 @@ async function openDashOrderDetail(orderNumber) {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)); const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
return `<div class="dif-item"> return `<div class="dif-item">
<div class="dif-row"> <div class="dif-row">
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span> <span class="dif-sku dif-codmat-link" onclick="openDashQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
${codmatText} ${codmatText}
</div> </div>
<div class="dif-row"> <div class="dif-row">
@@ -649,7 +645,7 @@ async function openDashOrderDetail(orderNumber) {
let tableHtml = items.map((item, idx) => { let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0); const valoare = Number(item.price || 0) * Number(item.quantity || 0);
return `<tr> return `<tr>
<td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td> <td><code class="codmat-link" onclick="openDashQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td> <td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td> <td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td> <td class="text-end">${item.quantity || 0}</td>
@@ -757,163 +753,23 @@ function renderReceipt(items, order) {
mobile.innerHTML = html; mobile.innerHTML = html;
} }
// ── Quick Map Modal ─────────────────────────────── // ── Quick Map Modal (uses shared openQuickMap) ───
function openQuickMap(sku, productName, orderNumber, itemIdx) { function openDashQuickMap(sku, productName, orderNumber, itemIdx) {
currentQmSku = sku;
currentQmOrderNumber = orderNumber;
document.getElementById('qmSku').textContent = sku;
document.getElementById('qmProductName').textContent = productName || '-';
document.getElementById('qmPctWarning').style.display = 'none';
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
// Check if this is a direct SKU (SKU=CODMAT in NOM_ARTICOLE)
const item = (window._detailItems || [])[itemIdx]; const item = (window._detailItems || [])[itemIdx];
const details = item?.codmat_details; const details = item?.codmat_details;
const isDirect = details?.length === 1 && details[0].direct === true; const isDirect = details?.length === 1 && details[0].direct === true;
const directInfo = document.getElementById('qmDirectInfo');
const saveBtn = document.getElementById('qmSaveBtn');
if (isDirect) { openQuickMap({
if (directInfo) { sku,
directInfo.innerHTML = `<i class="bi bi-info-circle"></i> SKU = CODMAT direct in nomenclator (<code>${escHtml(details[0].codmat)}</code> — ${escHtml(details[0].denumire || '')}).<br><small class="text-muted">Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).</small>`; productName,
directInfo.style.display = ''; isDirect,
} directInfo: isDirect ? { codmat: details[0].codmat, denumire: details[0].denumire } : null,
if (saveBtn) { prefill: (!isDirect && details?.length) ? details.map(d => ({ codmat: d.codmat, cantitate: d.cantitate_roa, denumire: d.denumire })) : null,
saveBtn.textContent = 'Suprascrie mapare'; onSave: () => {
} if (orderNumber) openDashOrderDetail(orderNumber);
addQmCodmatLine();
} else {
if (directInfo) directInfo.style.display = 'none';
if (saveBtn) saveBtn.textContent = 'Salveaza';
// Pre-populate with existing codmat_details if available
if (details && details.length > 0) {
details.forEach(d => {
addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate_roa, procent: d.procent_pret, denumire: d.denumire });
});
} else {
addQmCodmatLine();
}
}
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
}
function addQmCodmatLine(prefill) {
const container = document.getElementById('qmCodmatLines');
const idx = container.children.length;
const codmatVal = prefill?.codmat || '';
const cantVal = prefill?.cantitate || 1;
const pctVal = prefill?.procent || 100;
const denumireVal = prefill?.denumire || '';
const div = document.createElement('div');
div.className = 'qm-line';
div.innerHTML = `
<div class="qm-row">
<div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${escHtml(codmatVal)}">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
</div>
<input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
<input type="number" class="form-control form-control-sm qm-procent" value="${pctVal}" step="0.01" min="0" max="100" title="Procent %" style="width:70px">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
</div>
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${escHtml(denumireVal)}</div>
`;
container.appendChild(div);
const input = div.querySelector('.qm-codmat');
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(qmAcTimeout);
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function qmAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> &mdash; <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('.qm-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.qm-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('qmPctWarning').style.display = '';
return;
}
}
document.getElementById('qmPctWarning').style.display = 'none';
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
loadDashOrders(); loadDashOrders();
} else {
const msg = data.detail || data.error || 'Unknown';
document.getElementById('qmPctWarning').textContent = msg;
document.getElementById('qmPctWarning').style.display = '';
} }
} catch (err) { });
alert('Eroare: ' + err.message);
}
} }

View File

@@ -5,8 +5,6 @@ let runsPage = 1;
let logPollTimer = null; let logPollTimer = null;
let currentFilter = 'all'; let currentFilter = 'all';
let ordersPage = 1; let ordersPage = 1;
let currentQmSku = '';
let currentQmOrderNumber = '';
let ordersSortColumn = 'order_date'; let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc'; let ordersSortDirection = 'desc';
@@ -310,7 +308,7 @@ function renderCodmatCell(item) {
} }
// Multi-CODMAT: compact list // Multi-CODMAT: compact list
return item.codmat_details.map(d => return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>` `<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join(''); ).join('');
} }
@@ -384,8 +382,8 @@ async function openOrderDetail(orderNumber) {
if (mobileContainer) { if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => { mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatList = item.codmat_details?.length const codmatList = item.codmat_details?.length
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ') ? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`; : `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2); const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item"> return `<div class="dif-item">
<div class="dif-row"> <div class="dif-row">
@@ -403,7 +401,7 @@ async function openOrderDetail(orderNumber) {
document.getElementById('detailItemsBody').innerHTML = items.map(item => { document.getElementById('detailItemsBody').innerHTML = items.map(item => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2); const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`; const codmatCell = `<span class="codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
return `<tr> return `<tr>
<td><code>${esc(item.sku)}</code></td> <td><code>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td> <td>${esc(item.product_name || '-')}</td>
@@ -419,146 +417,17 @@ async function openOrderDetail(orderNumber) {
} }
} }
// ── Quick Map Modal (from order detail) ────────── // ── Quick Map Modal (uses shared openQuickMap) ───
let qmAcTimeout = null; function openLogsQuickMap(sku, productName, orderNumber) {
openQuickMap({
function openQuickMap(sku, productName, orderNumber) { sku,
currentQmSku = sku; productName,
currentQmOrderNumber = orderNumber; onSave: () => {
document.getElementById('qmSku').textContent = sku; if (orderNumber) openOrderDetail(orderNumber);
document.getElementById('qmProductName').textContent = productName || '-';
document.getElementById('qmPctWarning').style.display = 'none';
// Reset CODMAT lines
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
addQmCodmatLine();
// Show quick map on top of order detail (modal stacking)
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
}
function addQmCodmatLine() {
const container = document.getElementById('qmCodmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 qm-line';
div.innerHTML = `
<div class="mb-2 position-relative">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
<small class="text-muted qm-selected"></small>
</div>
<div class="row">
<div class="col-5">
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-5">
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
</div>
<div class="col-2 d-flex align-items-end">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
</div>
</div>
`;
container.appendChild(div);
// Setup autocomplete on the new input
const input = div.querySelector('.qm-codmat');
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(qmAcTimeout);
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function qmAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('.qm-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.qm-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
// Validate percentage sum for multi-line
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('qmPctWarning').style.display = '';
return;
}
}
document.getElementById('qmPctWarning').style.display = 'none';
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
// Refresh order detail items in the still-open modal
if (currentQmOrderNumber) openOrderDetail(currentQmOrderNumber);
// Refresh orders view
loadRunOrders(currentRunId, currentFilter, ordersPage); loadRunOrders(currentRunId, currentFilter, ordersPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
} }
} catch (err) { });
alert('Eroare: ' + err.message);
}
} }
// ── Init ──────────────────────────────────────── // ── Init ────────────────────────────────────────

View File

@@ -368,21 +368,17 @@ function addCodmatLine() {
const container = document.getElementById('codmatLines'); const container = document.getElementById('codmatLines');
const idx = container.children.length; const idx = container.children.length;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 codmat-line'; div.className = 'qm-line codmat-line';
div.innerHTML = ` div.innerHTML = `
<div class="row g-2 align-items-center"> <div class="qm-row">
<div class="col position-relative"> <div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}"> <input type="text" class="form-control form-control-sm cl-codmat" placeholder="CODMAT..." autocomplete="off" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div> <div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
<small class="text-muted cl-selected"></small>
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : '<div style="width:31px"></div>'}
</div> </div>
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
</div> </div>
<div class="qm-selected text-muted cl-selected" style="font-size:0.75rem;padding-left:2px"></div>
`; `;
container.appendChild(div); container.appendChild(div);

View File

@@ -204,6 +204,154 @@ function renderMobileSegmented(containerId, pills, onSelect) {
}); });
} }
// ── Shared Quick Map Modal ────────────────────────
let _qmOnSave = null;
let _qmAcTimeout = null;
/**
* Open the shared quick-map modal.
* @param {object} opts
* @param {string} opts.sku
* @param {string} opts.productName
* @param {Array} [opts.prefill] - [{codmat, cantitate, denumire}]
* @param {boolean}[opts.isDirect] - true if SKU=CODMAT direct
* @param {object} [opts.directInfo] - {codmat, denumire} for direct SKU info
* @param {function} opts.onSave - callback(sku, mappings) after successful save
*/
function openQuickMap(opts) {
_qmOnSave = opts.onSave || null;
document.getElementById('qmSku').textContent = opts.sku;
document.getElementById('qmProductName').textContent = opts.productName || '-';
document.getElementById('qmPctWarning').style.display = 'none';
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
const directInfo = document.getElementById('qmDirectInfo');
const saveBtn = document.getElementById('qmSaveBtn');
if (opts.isDirect && opts.directInfo) {
if (directInfo) {
directInfo.innerHTML = `<i class="bi bi-info-circle"></i> SKU = CODMAT direct in nomenclator (<code>${esc(opts.directInfo.codmat)}</code> — ${esc(opts.directInfo.denumire || '')}).<br><small class="text-muted">Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).</small>`;
directInfo.style.display = '';
}
if (saveBtn) saveBtn.textContent = 'Suprascrie mapare';
addQmCodmatLine();
} else {
if (directInfo) directInfo.style.display = 'none';
if (saveBtn) saveBtn.textContent = 'Salveaza';
if (opts.prefill && opts.prefill.length > 0) {
opts.prefill.forEach(d => addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate, denumire: d.denumire }));
} else {
addQmCodmatLine();
}
}
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
}
function addQmCodmatLine(prefill) {
const container = document.getElementById('qmCodmatLines');
const idx = container.children.length;
const codmatVal = prefill?.codmat || '';
const cantVal = prefill?.cantitate || 1;
const denumireVal = prefill?.denumire || '';
const div = document.createElement('div');
div.className = 'qm-line';
div.innerHTML = `
<div class="qm-row">
<div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${esc(codmatVal)}">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
</div>
<input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
</div>
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${esc(denumireVal)}</div>
`;
container.appendChild(div);
const input = div.querySelector('.qm-codmat');
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(_qmAcTimeout);
_qmAcTimeout = setTimeout(() => _qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function _qmAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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="_qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> &mdash; <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function _qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.qm-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
const sku = document.getElementById('qmSku').textContent;
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
if (_qmOnSave) _qmOnSave(sku, mappings);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// ── Dot helper ──────────────────────────────────── // ── Dot helper ────────────────────────────────────
function statusDot(status) { function statusDot(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ro"> <html lang="ro" style="color-scheme: light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,7 +7,7 @@
<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@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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %} {% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=14" rel="stylesheet"> <link href="{{ rp }}/static/css/style.css?v=17" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Top Navbar --> <!-- Top Navbar -->
@@ -27,9 +27,41 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Shared Quick Map Modal -->
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div style="margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
</div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
<span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span>
<span style="width:30px"></span>
</div>
<div id="qmCodmatLines"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
+ CODMAT
</button>
<div id="qmDirectInfo" class="alert alert-info mt-2" style="display:none; font-size:0.85rem; padding:8px 12px;"></div>
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></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" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
</div>
</div>
</div>
</div>
<script>window.ROOT_PATH = "{{ rp }}";</script> <script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=11"></script> <script src="{{ rp }}/static/js/shared.js?v=12"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -165,41 +165,8 @@
</div> </div>
<!-- Quick Map Modal (used from order detail) --> <!-- Quick Map Modal (used from order detail) -->
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div style="margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
</div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
<span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span>
<span style="width:70px">%</span>
<span style="width:30px"></span>
</div>
<div id="qmCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
+ CODMAT
</button>
<div id="qmDirectInfo" class="alert alert-info mt-2" style="display:none; font-size:0.85rem; padding:8px 12px;"></div>
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></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" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=21"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=24"></script>
{% endblock %} {% endblock %}

View File

@@ -151,37 +151,10 @@
</div> </div>
<!-- Quick Map Modal (used from order detail) --> <!-- Quick Map Modal (used from order detail) -->
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
</div>
<div id="qmCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></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="saveQuickMapping()">Salveaza</button>
</div>
</div>
</div>
</div>
<!-- Hidden field for pre-selected run from URL/server --> <!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}"> <input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=9"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=11"></script>
{% endblock %} {% endblock %}

View File

@@ -61,27 +61,31 @@
</div> </div>
<!-- Add/Edit Modal with multi-CODMAT support (R11) --> <!-- Add/Edit Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="addModal" tabindex="-1"> <div class="modal fade" id="addModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5> <h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-2">
<label class="form-label">SKU</label> <label class="form-label form-label-sm mb-1">SKU</label>
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284"> <input type="text" class="form-control form-control-sm" id="inputSku" placeholder="Ex: 8714858124284">
</div> </div>
<div class="mb-2" id="addModalProductName" style="display:none;"> <div id="addModalProductName" style="display:none; margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs web:</small> <strong id="inputProductName"></strong> <small class="text-muted">Produs:</small> <strong id="inputProductName"></strong>
</div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
<span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span>
<span style="width:30px"></span>
</div> </div>
<hr>
<div id="codmatLines"> <div id="codmatLines">
<!-- Dynamic CODMAT lines will be added here --> <!-- Dynamic CODMAT lines will be added here -->
</div> </div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addCodmatLine()"> <button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
<i class="bi bi-plus"></i> Adauga CODMAT + CODMAT
</button> </button>
<div id="pctWarning" class="text-danger mt-2" style="display:none;"></div> <div id="pctWarning" class="text-danger mt-2" style="display:none;"></div>
</div> </div>
@@ -146,5 +150,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=9"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=10"></script>
{% endblock %} {% endblock %}

View File

@@ -65,39 +65,10 @@
</div> </div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div> <div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
<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-2">
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
</div>
<div id="mapCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></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 %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
let currentMapSku = '';
let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let skuStatusFilter = 'unresolved'; let skuStatusFilter = 'unresolved';
let missingPerPage = 20; let missingPerPage = 20;
@@ -223,7 +194,7 @@ function renderMissingSkusTable(skus, data) {
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td> <td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td> <td>
${!s.resolved ${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza"> ? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
<i class="bi bi-link-45deg"></i> <i class="bi bi-link-45deg"></i>
</a>` </a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`} : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
@@ -234,7 +205,7 @@ function renderMissingSkusTable(skus, data) {
if (mobileList) { if (mobileList) {
mobileList.innerHTML = skus.map(s => { mobileList.innerHTML = skus.map(s => {
const actionHtml = !s.resolved const actionHtml = !s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>` ? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`; : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
const flatRowAttrs = !s.resolved const flatRowAttrs = !s.resolved
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"` ? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
@@ -259,132 +230,14 @@ function renderPagination(data) {
if (bot) bot.innerHTML = pagHtml; if (bot) bot.innerHTML = pagHtml;
} }
// ── Multi-CODMAT Map Modal ─────────────────────── // ── Map Modal (uses shared openQuickMap) ─────────
function openMapModal(sku, productName) { function openMapModal(sku, productName) {
currentMapSku = sku; openQuickMap({
document.getElementById('mapSku').textContent = sku; sku,
document.getElementById('mapProductName').textContent = productName || '-'; productName,
document.getElementById('mapPctWarning').style.display = 'none'; onSave: () => { loadMissingSkus(currentPage); }
const container = document.getElementById('mapCodmatLines');
container.innerHTML = '';
addMapCodmatLine();
new bootstrap.Modal(document.getElementById('mapModal')).show();
}
function addMapCodmatLine() {
const container = document.getElementById('mapCodmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 mc-line';
div.innerHTML = `
<div class="row g-2 align-items-center">
<div class="col position-relative">
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
<small class="text-muted mc-selected"></small>
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : '<div style="width:31px"></div>'}
</div>
</div>
`;
container.appendChild(div);
const input = div.querySelector('.mc-codmat');
const dropdown = div.querySelector('.mc-ac-dropdown');
const selected = div.querySelector('.mc-selected');
input.addEventListener('input', () => {
clearTimeout(mapAcTimeout);
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
}); });
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function mcAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function mcSelectArticle(el, codmat, label) {
const line = el.closest('.mc-line');
line.querySelector('.mc-codmat').value = codmat;
line.querySelector('.mc-selected').textContent = label;
line.querySelector('.mc-ac-dropdown').classList.add('d-none');
}
async function saveQuickMap() {
const lines = document.querySelectorAll('.mc-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.mc-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.mc-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('mapPctWarning').style.display = '';
return;
}
}
document.getElementById('mapPctWarning').style.display = 'none';
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissingSkus(currentPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
} }
function exportMissingCsv() { function exportMissingCsv() {

View File

@@ -34,7 +34,7 @@
-- separate_line: componentele se insereaza la pret plin + -- separate_line: componentele se insereaza la pret plin +
-- linii discount separate grupate pe cota TVA -- linii discount separate grupate pe cota TVA
-- p_id_pol_productie — politica de pret pentru articole de productie -- p_id_pol_productie — politica de pret pentru articole de productie
-- (cont_vanzare in 341/345); NULL = nu se foloseste -- (cont in 341/345); NULL = nu se foloseste
-- p_kit_discount_codmat — CODMAT-ul articolului discount (Mode separate_line) -- p_kit_discount_codmat — CODMAT-ul articolului discount (Mode separate_line)
-- p_kit_discount_id_pol — id_pol pentru liniile discount (Mode separate_line) -- p_kit_discount_id_pol — id_pol pentru liniile discount (Mode separate_line)
-- --
@@ -349,7 +349,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
-- Determina id_pol_comp: cont 341/345 → politica productie, altfel vanzare -- Determina id_pol_comp: cont 341/345 → politica productie, altfel vanzare
BEGIN BEGIN
SELECT NVL(na.cont_vanzare, '') INTO v_cont_vanz SELECT NVL(na.cont, '') INTO v_cont_vanz
FROM nom_articole na FROM nom_articole na
WHERE na.id_articol = v_kit_comps(v_comp_idx).id_articol WHERE na.id_articol = v_kit_comps(v_comp_idx).id_articol
AND ROWNUM = 1; AND ROWNUM = 1;