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>
377 lines
15 KiB
JavaScript
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, '&')
|
|
.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 = '<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' : ''}>«</button>`;
|
|
// Prev
|
|
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>‹</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' : ''}>›</button>`;
|
|
// Last
|
|
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>»</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> — <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>';
|
|
}
|
|
}
|