// 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 btn-primary' : '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); } } // ── 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 ''; } }