Files
gomag-vending/api/app/static/js/shared.js
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

377 lines
15 KiB
JavaScript

// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── 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 = '<div class="d-flex align-items-center gap-2 flex-wrap">';
// Per-page selector
if (opts && opts.perPage && opts.perPageFn) {
const options = opts.perPageOptions || [25, 50, 100, 250];
html += `<label class="per-page-label">Per pagina: <select class="select-compact ms-1" onchange="${opts.perPageFn}(this.value)">`;
options.forEach(v => {
html += `<option value="${v}"${v === opts.perPage ? ' selected' : ''}>${v}</option>`;
});
html += '</select></label>';
}
if (totalPages <= 1) {
html += '</div>';
return html;
}
html += '<div class="pagination-bar">';
// First
html += `<button class="page-btn" onclick="${goToFnName}(1)" ${currentPage <= 1 ? 'disabled' : ''}>&laquo;</button>`;
// Prev
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>&lsaquo;</button>`;
// 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 += `<span class="page-btn disabled page-ellipsis">…</span>`;
}
html += `<button class="page-btn page-number${p === currentPage ? ' active' : ''}" onclick="${goToFnName}(${p})">${p}</button>`;
lastP = p;
});
// Next
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>&rsaquo;</button>`;
// Last
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>&raquo;</button>`;
html += '</div></div>';
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 = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
}).join('')}</div>`;
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 = `<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 ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED':
case 'ALREADY_IMPORTED':
case 'COMPLETED':
case 'RESOLVED':
return '<span class="dot dot-green"></span>';
case 'SKIPPED':
case 'UNRESOLVED':
case 'INCOMPLETE':
return '<span class="dot dot-yellow"></span>';
case 'ERROR':
case 'FAILED':
return '<span class="dot dot-red"></span>';
case 'CANCELLED':
case 'DELETED_IN_ROA':
return '<span class="dot dot-gray"></span>';
default:
return '<span class="dot dot-gray"></span>';
}
}