Batch create after soft-delete was rejected because create_mapping() treated soft-deleted records as conflicts. Added auto_restore param that restores+updates instead of 409 when called from edit flow. Also removed readOnly on SKU input in edit modal. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
759 lines
32 KiB
JavaScript
759 lines
32 KiB
JavaScript
let currentPage = 1;
|
|
let mappingsPerPage = 50;
|
|
let currentSearch = '';
|
|
let searchTimeout = null;
|
|
let sortColumn = 'sku';
|
|
let sortDirection = 'asc';
|
|
let editingMapping = null; // {sku, codmat} when editing
|
|
let pctFilter = 'all';
|
|
|
|
// Load on page ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadMappings();
|
|
initAddModal();
|
|
initDeleteModal();
|
|
initPctFilterPills();
|
|
});
|
|
|
|
function debounceSearch() {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
currentSearch = document.getElementById('searchInput').value;
|
|
currentPage = 1;
|
|
loadMappings();
|
|
}, 300);
|
|
}
|
|
|
|
// ── Sorting (R7) ─────────────────────────────────
|
|
|
|
function sortBy(col) {
|
|
if (sortColumn === col) {
|
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortColumn = col;
|
|
sortDirection = 'asc';
|
|
}
|
|
currentPage = 1;
|
|
loadMappings();
|
|
}
|
|
|
|
function updateSortIcons() {
|
|
document.querySelectorAll('.sort-icon').forEach(span => {
|
|
const col = span.dataset.col;
|
|
if (col === sortColumn) {
|
|
span.textContent = sortDirection === 'asc' ? '\u2191' : '\u2193';
|
|
} else {
|
|
span.textContent = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Pct Filter Pills ─────────────────────────────
|
|
|
|
function initPctFilterPills() {
|
|
document.querySelectorAll('.filter-pill[data-pct]').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
pctFilter = this.dataset.pct;
|
|
currentPage = 1;
|
|
loadMappings();
|
|
});
|
|
});
|
|
}
|
|
|
|
function updatePctCounts(counts) {
|
|
if (!counts) return;
|
|
const elAll = document.getElementById('mCntAll');
|
|
const elComplete = document.getElementById('mCntComplete');
|
|
const elIncomplete = document.getElementById('mCntIncomplete');
|
|
if (elAll) elAll.textContent = counts.total || 0;
|
|
if (elComplete) elComplete.textContent = counts.complete || 0;
|
|
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
|
|
|
|
// Mobile segmented control
|
|
renderMobileSegmented('mappingsMobileSeg', [
|
|
{ label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' },
|
|
{ label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' },
|
|
{ label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' }
|
|
], (val) => {
|
|
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
|
|
const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`);
|
|
if (pill) pill.classList.add('active');
|
|
pctFilter = val;
|
|
currentPage = 1;
|
|
loadMappings();
|
|
});
|
|
}
|
|
|
|
// ── Load & Render ────────────────────────────────
|
|
|
|
async function loadMappings() {
|
|
const showInactive = document.getElementById('showInactive')?.checked;
|
|
const showDeleted = document.getElementById('showDeleted')?.checked;
|
|
const params = new URLSearchParams({
|
|
search: currentSearch,
|
|
page: currentPage,
|
|
per_page: mappingsPerPage,
|
|
sort_by: sortColumn,
|
|
sort_dir: sortDirection
|
|
});
|
|
if (showDeleted) params.set('show_deleted', 'true');
|
|
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
|
|
|
|
try {
|
|
const res = await fetch(`/api/mappings?${params}`);
|
|
const data = await res.json();
|
|
|
|
let mappings = data.mappings || [];
|
|
|
|
// Client-side filter for inactive unless toggle is on
|
|
// (keep deleted rows visible when showDeleted is on, even if inactive)
|
|
if (!showInactive) {
|
|
mappings = mappings.filter(m => m.activ || m.sters);
|
|
}
|
|
|
|
updatePctCounts(data.counts);
|
|
renderTable(mappings, showDeleted);
|
|
renderPagination(data);
|
|
updateSortIcons();
|
|
} catch (err) {
|
|
document.getElementById('mappingsFlatList').innerHTML =
|
|
`<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderTable(mappings, showDeleted) {
|
|
const container = document.getElementById('mappingsFlatList');
|
|
|
|
if (!mappings || mappings.length === 0) {
|
|
container.innerHTML = '<div class="flat-row text-muted py-4 justify-content-center">Nu exista mapari</div>';
|
|
return;
|
|
}
|
|
|
|
let prevSku = null;
|
|
let html = '';
|
|
mappings.forEach(m => {
|
|
const isNewGroup = m.sku !== prevSku;
|
|
if (isNewGroup) {
|
|
let pctBadge = '';
|
|
if (m.pct_total !== undefined) {
|
|
pctBadge = m.is_complete
|
|
? ` <span class="badge-pct complete">✓ 100%</span>`
|
|
: ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
|
|
}
|
|
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
|
|
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
|
|
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
|
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
|
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
|
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${pctBadge}
|
|
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
|
|
${m.sters
|
|
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
|
|
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">⋮</button>`
|
|
}
|
|
</div>`;
|
|
}
|
|
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
|
|
html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
|
|
<code>${esc(m.codmat)}</code>
|
|
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
|
|
<span class="text-nowrap" style="font-size:0.875rem">
|
|
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
|
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
|
|
· <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
|
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
|
|
</span>
|
|
</div>`;
|
|
prevSku = m.sku;
|
|
});
|
|
container.innerHTML = html;
|
|
|
|
// Wire context menu triggers
|
|
container.querySelectorAll('.context-menu-trigger').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const { sku, codmat, cantitate, procent } = btn.dataset;
|
|
const rect = btn.getBoundingClientRect();
|
|
showContextMenu(rect.left, rect.bottom + 2, [
|
|
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) },
|
|
{ label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true }
|
|
]);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Inline edit for flat-row values (cantitate / procent)
|
|
function editFlatValue(span, sku, codmat, field, currentValue) {
|
|
if (span.querySelector('input')) return;
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'number';
|
|
input.className = 'form-control form-control-sm d-inline';
|
|
input.value = currentValue;
|
|
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
|
|
input.style.width = '70px';
|
|
input.style.display = 'inline';
|
|
|
|
const originalText = span.textContent;
|
|
span.textContent = '';
|
|
span.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
const save = async () => {
|
|
const newValue = parseFloat(input.value);
|
|
if (isNaN(newValue) || newValue === currentValue) {
|
|
span.textContent = originalText;
|
|
return;
|
|
}
|
|
try {
|
|
const body = {};
|
|
body[field] = newValue;
|
|
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) { loadMappings(); }
|
|
else { span.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
|
|
} catch (err) { span.textContent = originalText; }
|
|
};
|
|
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
|
if (e.key === 'Escape') { span.textContent = originalText; }
|
|
});
|
|
}
|
|
|
|
function renderPagination(data) {
|
|
const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
|
|
const infoHtml = `<small class="text-muted me-auto">${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}</small>`;
|
|
const pagHtml = infoHtml + renderUnifiedPagination(data.page, data.pages || 1, 'goPage', pagOpts);
|
|
const top = document.getElementById('mappingsPagTop');
|
|
const bot = document.getElementById('mappingsPagBottom');
|
|
if (top) top.innerHTML = pagHtml;
|
|
if (bot) bot.innerHTML = pagHtml;
|
|
}
|
|
|
|
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
|
|
|
|
function goPage(p) {
|
|
currentPage = p;
|
|
loadMappings();
|
|
}
|
|
|
|
// ── Multi-CODMAT Add Modal (R11) ─────────────────
|
|
|
|
let acTimeouts = {};
|
|
|
|
function initAddModal() {
|
|
const modal = document.getElementById('addModal');
|
|
if (!modal) return;
|
|
|
|
modal.addEventListener('show.bs.modal', () => {
|
|
if (!editingMapping) {
|
|
clearAddForm();
|
|
}
|
|
});
|
|
modal.addEventListener('hidden.bs.modal', () => {
|
|
editingMapping = null;
|
|
document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
|
|
});
|
|
}
|
|
|
|
function clearAddForm() {
|
|
document.getElementById('inputSku').value = '';
|
|
document.getElementById('inputSku').readOnly = false;
|
|
document.getElementById('addModalProductName').style.display = 'none';
|
|
document.getElementById('pctWarning').style.display = 'none';
|
|
document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
|
|
const container = document.getElementById('codmatLines');
|
|
container.innerHTML = '';
|
|
addCodmatLine();
|
|
}
|
|
|
|
async function openEditModal(sku, codmat, cantitate, procent) {
|
|
editingMapping = { sku, codmat };
|
|
document.getElementById('addModalTitle').textContent = 'Editare Mapare';
|
|
document.getElementById('inputSku').value = sku;
|
|
document.getElementById('inputSku').readOnly = false;
|
|
document.getElementById('pctWarning').style.display = 'none';
|
|
|
|
const container = document.getElementById('codmatLines');
|
|
container.innerHTML = '';
|
|
|
|
try {
|
|
// Fetch all CODMATs for this SKU
|
|
const res = await fetch(`/api/mappings?search=${encodeURIComponent(sku)}&per_page=100`);
|
|
const data = await res.json();
|
|
const allMappings = (data.mappings || []).filter(m => m.sku === sku && !m.sters);
|
|
|
|
// Show product name if available
|
|
const productName = allMappings[0]?.product_name || '';
|
|
const productNameEl = document.getElementById('addModalProductName');
|
|
const productNameText = document.getElementById('inputProductName');
|
|
if (productName && productNameEl && productNameText) {
|
|
productNameText.textContent = productName;
|
|
productNameEl.style.display = '';
|
|
}
|
|
|
|
if (allMappings.length === 0) {
|
|
// Fallback to single line with passed values
|
|
addCodmatLine();
|
|
const line = container.querySelector('.codmat-line');
|
|
if (line) {
|
|
line.querySelector('.cl-codmat').value = codmat;
|
|
line.querySelector('.cl-cantitate').value = cantitate;
|
|
line.querySelector('.cl-procent').value = procent;
|
|
}
|
|
} else {
|
|
for (const m of allMappings) {
|
|
addCodmatLine();
|
|
const lines = container.querySelectorAll('.codmat-line');
|
|
const line = lines[lines.length - 1];
|
|
line.querySelector('.cl-codmat').value = m.codmat;
|
|
if (m.denumire) {
|
|
line.querySelector('.cl-selected').textContent = m.denumire;
|
|
}
|
|
line.querySelector('.cl-cantitate').value = m.cantitate_roa;
|
|
line.querySelector('.cl-procent').value = m.procent_pret;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Fallback on error
|
|
addCodmatLine();
|
|
const line = container.querySelector('.codmat-line');
|
|
if (line) {
|
|
line.querySelector('.cl-codmat').value = codmat;
|
|
line.querySelector('.cl-cantitate').value = cantitate;
|
|
line.querySelector('.cl-procent').value = procent;
|
|
}
|
|
}
|
|
|
|
new bootstrap.Modal(document.getElementById('addModal')).show();
|
|
}
|
|
|
|
function addCodmatLine() {
|
|
const container = document.getElementById('codmatLines');
|
|
const idx = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'border rounded p-2 mb-2 codmat-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 cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}">
|
|
<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" style="width:90px">
|
|
<input type="number" class="form-control form-control-sm cl-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('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : '<div style="width:31px"></div>'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
|
|
// Setup autocomplete
|
|
const input = div.querySelector('.cl-codmat');
|
|
const dropdown = div.querySelector('.cl-ac-dropdown');
|
|
const selected = div.querySelector('.cl-selected');
|
|
|
|
input.addEventListener('input', () => {
|
|
const key = 'cl_' + idx;
|
|
clearTimeout(acTimeouts[key]);
|
|
acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
});
|
|
}
|
|
|
|
async function clAutocomplete(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="clSelectArticle(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 clSelectArticle(el, codmat, label) {
|
|
const line = el.closest('.codmat-line');
|
|
line.querySelector('.cl-codmat').value = codmat;
|
|
line.querySelector('.cl-selected').textContent = label;
|
|
line.querySelector('.cl-ac-dropdown').classList.add('d-none');
|
|
}
|
|
|
|
async function saveMapping() {
|
|
const sku = document.getElementById('inputSku').value.trim();
|
|
if (!sku) { alert('SKU este obligatoriu'); return; }
|
|
|
|
const lines = document.querySelectorAll('.codmat-line');
|
|
const mappings = [];
|
|
|
|
for (const line of lines) {
|
|
const codmat = line.querySelector('.cl-codmat').value.trim();
|
|
const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1;
|
|
const procent = parseFloat(line.querySelector('.cl-procent').value) || 100;
|
|
if (!codmat) continue;
|
|
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
|
|
}
|
|
|
|
if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; }
|
|
|
|
// Validate percentage 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('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
|
document.getElementById('pctWarning').style.display = '';
|
|
return;
|
|
}
|
|
}
|
|
document.getElementById('pctWarning').style.display = 'none';
|
|
|
|
try {
|
|
let res;
|
|
|
|
if (editingMapping) {
|
|
if (mappings.length === 1) {
|
|
// Single CODMAT edit: use existing PUT endpoint
|
|
res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
new_sku: sku,
|
|
new_codmat: mappings[0].codmat,
|
|
cantitate_roa: mappings[0].cantitate_roa,
|
|
procent_pret: mappings[0].procent_pret
|
|
})
|
|
});
|
|
} else {
|
|
// Multi-CODMAT set: delete all existing then create new batch
|
|
const oldSku = editingMapping.sku;
|
|
const existRes = await fetch(`/api/mappings?search=${encodeURIComponent(oldSku)}&per_page=100`);
|
|
const existData = await existRes.json();
|
|
const existing = (existData.mappings || []).filter(m => m.sku === oldSku && !m.sters);
|
|
|
|
// Delete each existing CODMAT for old SKU
|
|
for (const m of existing) {
|
|
await fetch(`/api/mappings/${encodeURIComponent(m.sku)}/${encodeURIComponent(m.codmat)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
|
|
// Create new batch with auto_restore (handles just-soft-deleted records)
|
|
res = await fetch('/api/mappings/batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sku, mappings, auto_restore: true })
|
|
});
|
|
}
|
|
} else 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, procent_pret: mappings[0].procent_pret })
|
|
});
|
|
} 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('addModal')).hide();
|
|
editingMapping = null;
|
|
loadMappings();
|
|
} else if (res.status === 409) {
|
|
handleMappingConflict(data);
|
|
} else {
|
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// ── Inline Add Row ──────────────────────────────
|
|
|
|
let inlineAddVisible = false;
|
|
|
|
function showInlineAddRow() {
|
|
// On mobile, open the full modal instead
|
|
if (window.innerWidth < 768) {
|
|
new bootstrap.Modal(document.getElementById('addModal')).show();
|
|
return;
|
|
}
|
|
|
|
if (inlineAddVisible) return;
|
|
inlineAddVisible = true;
|
|
|
|
const container = document.getElementById('mappingsFlatList');
|
|
const row = document.createElement('div');
|
|
row.id = 'inlineAddRow';
|
|
row.className = 'flat-row';
|
|
row.style.background = '#eff6ff';
|
|
row.style.gap = '0.5rem';
|
|
row.innerHTML = `
|
|
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
|
<div class="position-relative" style="flex:1;min-width:0">
|
|
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
|
|
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
|
|
<small class="text-muted" id="inlineSelected"></small>
|
|
</div>
|
|
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
|
|
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
|
|
<button class="btn btn-sm btn-success" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
|
|
`;
|
|
container.insertBefore(row, container.firstChild);
|
|
document.getElementById('inlineSku').focus();
|
|
|
|
// Setup autocomplete for inline CODMAT
|
|
const input = document.getElementById('inlineCodmat');
|
|
const dropdown = document.getElementById('inlineAcDropdown');
|
|
const selected = document.getElementById('inlineSelected');
|
|
let inlineAcTimeout = null;
|
|
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(inlineAcTimeout);
|
|
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
});
|
|
}
|
|
|
|
async function inlineAutocomplete(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="inlineSelectArticle('${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 inlineSelectArticle(codmat, label) {
|
|
document.getElementById('inlineCodmat').value = codmat;
|
|
document.getElementById('inlineSelected').textContent = label;
|
|
document.getElementById('inlineAcDropdown').classList.add('d-none');
|
|
}
|
|
|
|
async function saveInlineMapping() {
|
|
const sku = document.getElementById('inlineSku').value.trim();
|
|
const codmat = document.getElementById('inlineCodmat').value.trim();
|
|
const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1;
|
|
const procent = parseFloat(document.getElementById('inlineProcent').value) || 100;
|
|
|
|
if (!sku) { alert('SKU este obligatoriu'); return; }
|
|
if (!codmat) { alert('CODMAT este obligatoriu'); return; }
|
|
|
|
try {
|
|
const res = await fetch('/api/mappings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
cancelInlineAdd();
|
|
loadMappings();
|
|
} else if (res.status === 409) {
|
|
handleMappingConflict(data);
|
|
} else {
|
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function cancelInlineAdd() {
|
|
const row = document.getElementById('inlineAddRow');
|
|
if (row) row.remove();
|
|
inlineAddVisible = false;
|
|
}
|
|
|
|
// ── Toggle Active with Toast Undo ────────────────
|
|
|
|
async function toggleActive(sku, codmat, currentActive) {
|
|
const newActive = currentActive ? 0 : 1;
|
|
try {
|
|
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ activ: newActive })
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) return;
|
|
|
|
loadMappings();
|
|
|
|
// Show toast with undo
|
|
const action = newActive ? 'activata' : 'dezactivata';
|
|
showUndoToast(`Mapare ${sku} \u2192 ${codmat} ${action}.`, () => {
|
|
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ activ: currentActive })
|
|
}).then(() => loadMappings());
|
|
});
|
|
} catch (err) { alert('Eroare: ' + err.message); }
|
|
}
|
|
|
|
function showUndoToast(message, undoCallback) {
|
|
document.getElementById('toastMessage').textContent = message;
|
|
const undoBtn = document.getElementById('toastUndoBtn');
|
|
// Clone to remove old listeners
|
|
const newBtn = undoBtn.cloneNode(true);
|
|
undoBtn.parentNode.replaceChild(newBtn, undoBtn);
|
|
newBtn.id = 'toastUndoBtn';
|
|
if (undoCallback) {
|
|
newBtn.style.display = '';
|
|
newBtn.addEventListener('click', () => {
|
|
undoCallback();
|
|
const toastEl = document.getElementById('undoToast');
|
|
const inst = bootstrap.Toast.getInstance(toastEl);
|
|
if (inst) inst.hide();
|
|
});
|
|
} else {
|
|
newBtn.style.display = 'none';
|
|
}
|
|
const toast = new bootstrap.Toast(document.getElementById('undoToast'));
|
|
toast.show();
|
|
}
|
|
|
|
// ── Delete with Modal Confirmation ──────────────
|
|
|
|
let pendingDelete = null;
|
|
|
|
function initDeleteModal() {
|
|
const btn = document.getElementById('confirmDeleteBtn');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', async () => {
|
|
if (!pendingDelete) return;
|
|
const { sku, codmat } = pendingDelete;
|
|
try {
|
|
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
const data = await res.json();
|
|
bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
|
|
if (data.success) loadMappings();
|
|
else alert('Eroare: ' + (data.error || 'Delete failed'));
|
|
} catch (err) {
|
|
bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
pendingDelete = null;
|
|
});
|
|
}
|
|
|
|
function deleteMappingConfirm(sku, codmat) {
|
|
pendingDelete = { sku, codmat };
|
|
document.getElementById('deleteSkuText').textContent = sku;
|
|
document.getElementById('deleteCodmatText').textContent = codmat;
|
|
new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show();
|
|
}
|
|
|
|
// ── Restore Deleted ──────────────────────────────
|
|
|
|
async function restoreMapping(sku, codmat) {
|
|
try {
|
|
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) loadMappings();
|
|
else alert('Eroare: ' + (data.error || 'Restore failed'));
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// ── CSV ──────────────────────────────────────────
|
|
|
|
async function importCsv() {
|
|
const fileInput = document.getElementById('csvFile');
|
|
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', fileInput.files[0]);
|
|
|
|
try {
|
|
const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
let msg = `${data.processed} mapări importate`;
|
|
if (data.skipped_no_codmat > 0) {
|
|
msg += `, ${data.skipped_no_codmat} rânduri fără CODMAT omise`;
|
|
}
|
|
let html = `<div class="alert alert-success">${msg}</div>`;
|
|
if (data.errors && data.errors.length > 0) {
|
|
html += `<div class="alert alert-warning">Erori (${data.errors.length}): <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
|
|
}
|
|
document.getElementById('importResult').innerHTML = html;
|
|
loadMappings();
|
|
} catch (err) {
|
|
document.getElementById('importResult').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
|
|
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
|
|
|
|
// ── Duplicate / Conflict handling ────────────────
|
|
|
|
function handleMappingConflict(data) {
|
|
const msg = data.error || 'Conflict la salvare';
|
|
if (data.can_restore) {
|
|
const restore = confirm(`${msg}\n\nDoriti sa restaurati maparea stearsa?`);
|
|
if (restore) {
|
|
// Find sku/codmat from the inline row or modal
|
|
const sku = (document.getElementById('inlineSku') || document.getElementById('inputSku'))?.value?.trim();
|
|
const codmat = (document.getElementById('inlineCodmat') || document.querySelector('.cl-codmat'))?.value?.trim();
|
|
if (sku && codmat) {
|
|
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.success) { cancelInlineAdd(); loadMappings(); }
|
|
else alert('Eroare la restaurare: ' + (d.error || ''));
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
showUndoToast(msg, null);
|
|
// Show non-dismissible inline error
|
|
const warn = document.getElementById('pctWarning');
|
|
if (warn) { warn.textContent = msg; warn.style.display = ''; }
|
|
}
|
|
}
|
|
|