// shared.js - Unified utilities for all pages // ── Root path patch — prepend ROOT_PATH to all relative fetch calls ─────── (function() { const _fetch = window.fetch.bind(window); window.fetch = function(url, ...args) { if (typeof url === 'string' && url.startsWith('/') && window.ROOT_PATH) { url = window.ROOT_PATH + url; } return _fetch(url, ...args); }; })(); // ── HTML escaping ───────────────────────────────── function esc(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ── Date formatting ─────────────────────────────── function fmtDate(dateStr, includeSeconds) { if (!dateStr) return '-'; try { const d = new Date(dateStr); const hasTime = dateStr.includes(':'); if (hasTime) { const opts = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }; if (includeSeconds) opts.second = '2-digit'; return d.toLocaleString('ro-RO', opts); } return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return dateStr; } } // ── Unified Pagination ──────────────────────────── /** * Renders a full pagination bar with First/Prev/numbers/Next/Last. * @param {number} currentPage * @param {number} totalPages * @param {string} goToFnName - name of global function to call with page number * @param {object} [opts] - optional: { perPage, perPageFn, perPageOptions } * @returns {string} HTML string */ function renderUnifiedPagination(currentPage, totalPages, goToFnName, opts) { if (totalPages <= 1 && !(opts && opts.perPage)) { return ''; } let html = '
'; // Per-page selector if (opts && opts.perPage && opts.perPageFn) { const options = opts.perPageOptions || [25, 50, 100, 250]; html += `'; } if (totalPages <= 1) { html += '
'; return html; } html += '
'; // First html += ``; // Prev html += ``; // Page numbers with ellipsis const range = 2; let pages = []; for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= currentPage - range && i <= currentPage + range)) { pages.push(i); } } let lastP = 0; pages.forEach(p => { if (lastP && p - lastP > 1) { html += ``; } html += ``; lastP = p; }); // Next html += ``; // Last html += ``; html += '
'; return html; } // ── Context Menu ────────────────────────────────── let _activeContextMenu = null; function closeAllContextMenus() { if (_activeContextMenu) { _activeContextMenu.remove(); _activeContextMenu = null; } } document.addEventListener('click', closeAllContextMenus); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllContextMenus(); }); /** * Show a context menu at the given position. * @param {number} x - clientX * @param {number} y - clientY * @param {Array} items - [{label, action, danger}] */ function showContextMenu(x, y, items) { closeAllContextMenus(); const menu = document.createElement('div'); menu.className = 'context-menu'; items.forEach(item => { const btn = document.createElement('button'); btn.className = 'context-menu-item' + (item.danger ? ' text-danger' : ''); btn.textContent = item.label; btn.addEventListener('click', (e) => { e.stopPropagation(); closeAllContextMenus(); item.action(); }); menu.appendChild(btn); }); document.body.appendChild(menu); _activeContextMenu = menu; // Position menu, keeping it within viewport const rect = menu.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let left = x; let top = y; if (left + 160 > vw) left = vw - 165; if (top + rect.height > vh) top = vh - rect.height - 5; menu.style.left = left + 'px'; menu.style.top = top + 'px'; } /** * Wire right-click on desktop + three-dots button on mobile for a table. * @param {string} rowSelector - CSS selector for clickable rows * @param {function} menuItemsFn - called with row element, returns [{label, action, danger}] */ function initContextMenus(rowSelector, menuItemsFn) { document.addEventListener('contextmenu', (e) => { const row = e.target.closest(rowSelector); if (!row) return; e.preventDefault(); showContextMenu(e.clientX, e.clientY, menuItemsFn(row)); }); document.addEventListener('click', (e) => { const trigger = e.target.closest('.context-menu-trigger'); if (!trigger) return; const row = trigger.closest(rowSelector); if (!row) return; e.stopPropagation(); const rect = trigger.getBoundingClientRect(); showContextMenu(rect.left, rect.bottom + 2, menuItemsFn(row)); }); } // ── Mobile segmented control ───────────────────── /** * Render a Bootstrap btn-group segmented control for mobile. * @param {string} containerId - ID of the container div * @param {Array} pills - [{label, count, colorClass, value, active}] * @param {function} onSelect - callback(value) */ function renderMobileSegmented(containerId, pills, onSelect) { const container = document.getElementById(containerId); if (!container) return; const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem'; container.innerHTML = `
${pills.map(p => { const cls = p.active ? 'btn seg-active' : 'btn btn-outline-secondary'; const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : ''; return ``; }).join('')}
`; container.querySelectorAll('[data-seg-value]').forEach(btn => { btn.addEventListener('click', () => onSelect(btn.dataset.segValue)); }); } // ── 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 = ` SKU = CODMAT direct in nomenclator (${esc(opts.directInfo.codmat)} — ${esc(opts.directInfo.denumire || '')}).
Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).`; 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 = `
${idx > 0 ? `` : ''}
${esc(denumireVal)}
`; 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 => `
${esc(r.codmat)}${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
` ).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); } } // ── Shared helpers (moved from dashboard.js/logs.js) ─ function fmtCost(v) { return v > 0 ? Number(v).toFixed(2) : '–'; } function fmtNum(v) { return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function orderStatusBadge(status) { switch ((status || '').toUpperCase()) { case 'IMPORTED': return 'Importat'; case 'ALREADY_IMPORTED': return 'Deja importat'; case 'SKIPPED': return 'Omis'; case 'ERROR': return 'Eroare'; case 'CANCELLED': return 'Anulat'; case 'DELETED_IN_ROA': return 'Sters din ROA'; default: return `${esc(status)}`; } } function renderCodmatCell(item) { if (!item.codmat_details || item.codmat_details.length === 0) { return `${esc(item.codmat || '-')}`; } if (item.codmat_details.length === 1) { const d = item.codmat_details[0]; if (d.direct) { return `${esc(d.codmat)} direct`; } return `${esc(d.codmat)}`; } return item.codmat_details.map(d => `
${esc(d.codmat)} \xd7${d.cantitate_roa}
` ).join(''); } function computeDiscountSplit(items, order) { if (order.discount_split && typeof order.discount_split === 'object') return order.discount_split; const byRate = {}; items.forEach(item => { const rate = item.vat != null ? Number(item.vat) : null; if (rate === null) return; if (!byRate[rate]) byRate[rate] = 0; byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0); }); const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b)); if (rates.length === 0) return null; const grandTotal = rates.reduce((s, r) => s + byRate[r], 0); if (grandTotal <= 0) return null; const split = {}; let remaining = order.discount_total; rates.forEach((rate, i) => { if (i === rates.length - 1) { split[rate] = Math.round(remaining * 100) / 100; } else { const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100; split[rate] = amt; remaining -= amt; } }); return split; } function _renderReceipt(items, order) { const desktop = document.getElementById('detailReceipt'); const mobile = document.getElementById('detailReceiptMobile'); if (!desktop && !mobile) return; if (!items.length) { if (desktop) desktop.innerHTML = ''; if (mobile) mobile.innerHTML = ''; return; } const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0); const discount = Number(order.discount_total || 0); const transport = Number(order.delivery_cost || 0); const total = order.order_total != null ? fmtNum(order.order_total) : '-'; let dHtml = `Articole: ${fmtNum(articole)}`; if (discount > 0) dHtml += `Discount: \u2013${fmtNum(discount)}`; if (transport > 0) dHtml += `Transport: ${fmtNum(transport)}`; dHtml += `Total: ${total} lei`; if (desktop) desktop.innerHTML = dHtml; let mHtml = `Art: ${fmtNum(articole)}`; if (discount > 0) mHtml += `Disc: \u2013${fmtNum(discount)}`; if (transport > 0) mHtml += `Transp: ${fmtNum(transport)}`; mHtml += `Total: ${total} lei`; if (mobile) mobile.innerHTML = mHtml; } // ── Order Detail Modal (shared) ────────────────── /** * Render and show the order detail modal. * @param {string} orderNumber * @param {object} opts * @param {function} opts.onQuickMap - (sku, productName, orderNumber, itemIdx) => void * @param {function} [opts.onAfterRender] - (order, items) => void */ async function renderOrderDetailModal(orderNumber, opts) { opts = opts || {}; // Reset modal state document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; document.getElementById('detailCustomer').textContent = '...'; document.getElementById('detailDate').textContent = ''; document.getElementById('detailStatus').innerHTML = ''; document.getElementById('detailIdComanda').textContent = '-'; document.getElementById('detailIdPartener').textContent = '-'; document.getElementById('detailIdAdresaFact').textContent = '-'; document.getElementById('detailIdAdresaLivr').textContent = '-'; document.getElementById('detailItemsBody').innerHTML = 'Se incarca...'; document.getElementById('detailError').style.display = 'none'; const receiptEl = document.getElementById('detailReceipt'); if (receiptEl) receiptEl.innerHTML = ''; const receiptMEl = document.getElementById('detailReceiptMobile'); if (receiptMEl) receiptMEl.innerHTML = ''; const invInfo = document.getElementById('detailInvoiceInfo'); if (invInfo) invInfo.style.display = 'none'; const mobileContainer = document.getElementById('detailItemsMobile'); if (mobileContainer) mobileContainer.innerHTML = ''; const priceCheckEl = document.getElementById('detailPriceCheck'); if (priceCheckEl) priceCheckEl.innerHTML = ''; const modalEl = document.getElementById('orderDetailModal'); const existing = bootstrap.Modal.getInstance(modalEl); if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); } try { const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`); const data = await res.json(); if (data.error) { document.getElementById('detailError').textContent = data.error; document.getElementById('detailError').style.display = ''; return; } const order = data.order || {}; document.getElementById('detailCustomer').textContent = order.customer_name || '-'; document.getElementById('detailDate').textContent = fmtDate(order.order_date); document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status); // Price check badge const priceCheckEl = document.getElementById('detailPriceCheck'); if (priceCheckEl) { const pc = order.price_check; if (!pc || pc.oracle_available === false) { priceCheckEl.innerHTML = 'Preturi ROA indisponibile'; } else if (pc.mismatches === 0) { priceCheckEl.innerHTML = '✓ Preturi OK'; } else { priceCheckEl.innerHTML = `${pc.mismatches} diferente de pret`; } } document.getElementById('detailIdComanda').textContent = order.id_comanda || '-'; document.getElementById('detailIdPartener').textContent = order.id_partener || '-'; document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-'; document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-'; // Invoice info const inv = order.invoice; if (inv && inv.facturat) { const serie = inv.serie_act || ''; const numar = inv.numar_act || ''; document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar; document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-'; if (invInfo) invInfo.style.display = ''; } if (order.error_message) { document.getElementById('detailError').textContent = order.error_message; document.getElementById('detailError').style.display = ''; } const items = data.items || []; if (items.length === 0) { document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; return; } // Store items for quick map pre-population window._detailItems = items; const qmFn = opts.onQuickMap ? opts.onQuickMap.name || '_sharedQuickMap' : null; // Mobile article flat list if (mobileContainer) { let mobileHtml = items.map((item, idx) => { const codmatText = item.codmat_details?.length ? item.codmat_details.map(d => `${esc(d.codmat)}${d.direct ? ' direct' : ''}`).join(' ') : `${esc(item.codmat || '–')}`; const valoare = (Number(item.price || 0) * Number(item.quantity || 0)); const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : ''; const priceInfo = (order.price_check?.items || {})[idx]; const priceMismatchHtml = priceInfo?.match === false ? `
ROA: ${fmtNum(priceInfo.pret_roa)} lei
` : ''; return `
${esc(item.sku)} ${codmatText}
${esc(item.product_name || '–')} x${item.quantity || 0} ${fmtNum(valoare)} lei TVA ${item.vat != null ? Number(item.vat) : '?'}
${priceMismatchHtml}
`; }).join(''); // Transport row (mobile) if (order.delivery_cost > 0) { const tVat = order.transport_vat || '21'; mobileHtml += `
Transport x1 ${fmtNum(order.delivery_cost)} lei TVA ${tVat}
`; } // Discount rows (mobile) if (order.discount_total > 0) { const discSplit = computeDiscountSplit(items, order); if (discSplit) { Object.entries(discSplit) .sort(([a], [b]) => Number(a) - Number(b)) .forEach(([rate, amt]) => { if (amt > 0) mobileHtml += `
Discount x\u20131 ${fmtNum(amt)} lei TVA ${Number(rate)}
`; }); } else { mobileHtml += `
Discount x\u20131 ${fmtNum(order.discount_total)} lei
`; } } mobileContainer.innerHTML = '
' + mobileHtml + '
'; } // Desktop items table const clickAttrFn = (item, idx) => opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare"` : ''; let tableHtml = items.map((item, idx) => { const valoare = Number(item.price || 0) * Number(item.quantity || 0); const priceInfo = (order.price_check?.items || {})[idx]; const pretRoaHtml = priceInfo?.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '–'; let matchDot, rowStyle; if (priceInfo == null) { matchDot = ''; rowStyle = ''; } else if (priceInfo.match === false) { matchDot = ''; rowStyle = ' style="background:var(--error-light)"'; } else { matchDot = ''; rowStyle = ''; } return ` ${esc(item.sku)} ${esc(item.product_name || '-')} ${renderCodmatCell(item)} ${item.quantity || 0} ${item.price != null ? fmtNum(item.price) : '-'} ${pretRoaHtml} ${item.vat != null ? Number(item.vat) : '-'} ${fmtNum(valoare)} ${matchDot} `; }).join(''); // Transport row if (order.delivery_cost > 0) { const tVat = order.transport_vat || '21'; const tCodmat = order.transport_codmat || ''; tableHtml += ` Transport ${tCodmat ? '' + esc(tCodmat) + '' : ''} 1${fmtNum(order.delivery_cost)} ${tVat}${fmtNum(order.delivery_cost)} `; } // Discount rows (split by VAT rate) if (order.discount_total > 0) { const dCodmat = order.discount_codmat || ''; const discSplit = computeDiscountSplit(items, order); if (discSplit) { Object.entries(discSplit) .sort(([a], [b]) => Number(a) - Number(b)) .forEach(([rate, amt]) => { if (amt > 0) tableHtml += ` Discount ${dCodmat ? '' + esc(dCodmat) + '' : ''} \u20131${fmtNum(amt)} ${Number(rate)}\u2013${fmtNum(amt)} `; }); } else { tableHtml += ` Discount ${dCodmat ? '' + esc(dCodmat) + '' : ''} \u20131${fmtNum(order.discount_total)} -\u2013${fmtNum(order.discount_total)} `; } } document.getElementById('detailItemsBody').innerHTML = tableHtml; _renderReceipt(items, order); if (opts.onAfterRender) opts.onAfterRender(order, items); } catch (err) { document.getElementById('detailError').textContent = err.message; document.getElementById('detailError').style.display = ''; } } // Global quick map dispatcher — set by each page let _sharedModalQuickMapFn = null; function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) { if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx); } // ── Dot helper ──────────────────────────────────── function statusDot(status) { switch ((status || '').toUpperCase()) { case 'IMPORTED': case 'ALREADY_IMPORTED': case 'COMPLETED': case 'RESOLVED': return ''; case 'SKIPPED': case 'UNRESOLVED': case 'INCOMPLETE': return ''; case 'ERROR': case 'FAILED': return ''; case 'CANCELLED': case 'DELETED_IN_ROA': return ''; default: return ''; } }