Add Pydantic validators and service-level checks that reject empty SKU/CODMAT on create/edit (400). CSV import now silently skips rows without CODMAT and counts them in skipped_no_codmat instead of treating them as errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
721 lines
29 KiB
JavaScript
721 lines
29 KiB
JavaScript
let currentPage = 1;
|
|
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;
|
|
}
|
|
|
|
// ── 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: 50,
|
|
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('mappingsBody').innerHTML =
|
|
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function renderTable(mappings, showDeleted) {
|
|
const tbody = document.getElementById('mappingsBody');
|
|
|
|
if (!mappings || mappings.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>';
|
|
return;
|
|
}
|
|
|
|
// Group by SKU for visual grouping (R6)
|
|
let html = '';
|
|
let prevSku = null;
|
|
let groupIdx = 0;
|
|
let skuGroupCounts = {};
|
|
|
|
// Count items per SKU
|
|
mappings.forEach(m => {
|
|
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
|
|
});
|
|
|
|
mappings.forEach((m, i) => {
|
|
const isNewGroup = m.sku !== prevSku;
|
|
if (isNewGroup) groupIdx++;
|
|
const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
|
|
const isMulti = skuGroupCounts[m.sku] > 1;
|
|
const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
|
|
const deletedClass = m.sters ? 'mapping-deleted' : '';
|
|
|
|
// SKU cell: show only on first row of group
|
|
let skuCell, productCell;
|
|
if (isNewGroup) {
|
|
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
|
|
// Percentage total badge
|
|
let pctBadge = '';
|
|
if (m.pct_total !== undefined) {
|
|
if (m.is_complete) {
|
|
pctBadge = ` <span class="badge-pct complete" title="100% alocat">✓ 100%</span>`;
|
|
} else {
|
|
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(2) : m.pct_total;
|
|
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">⚠ ${pctVal}%</span>`;
|
|
}
|
|
}
|
|
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`;
|
|
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
|
|
} else {
|
|
skuCell = '';
|
|
productCell = '';
|
|
}
|
|
|
|
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}">
|
|
${skuCell}
|
|
${productCell}
|
|
<td><code>${esc(m.codmat)}</code></td>
|
|
<td>${esc(m.denumire || '-')}</td>
|
|
<td>${esc(m.um || '-')}</td>
|
|
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td>
|
|
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td>
|
|
<td>
|
|
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'}
|
|
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
|
|
${m.activ ? 'Activ' : 'Inactiv'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
|
|
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
|
|
<i class="bi bi-trash"></i>
|
|
</button>`}
|
|
</td>
|
|
</tr>`;
|
|
|
|
prevSku = m.sku;
|
|
});
|
|
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
function renderPagination(data) {
|
|
const info = document.getElementById('pageInfo');
|
|
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
|
|
|
|
const ul = document.getElementById('pagination');
|
|
if (data.pages <= 1) { ul.innerHTML = ''; return; }
|
|
|
|
let html = '';
|
|
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">«</a></li>`;
|
|
|
|
let start = Math.max(1, data.page - 3);
|
|
let end = Math.min(data.pages, start + 6);
|
|
start = Math.max(1, end - 6);
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
|
|
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
|
|
}
|
|
|
|
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">»</a></li>`;
|
|
|
|
ul.innerHTML = html;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 = '';
|
|
addCodmatLine();
|
|
|
|
// Pre-fill the CODMAT line
|
|
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) {
|
|
// Edit mode: use PUT /api/mappings/{old_sku}/{old_codmat}/edit
|
|
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 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() {
|
|
if (inlineAddVisible) return;
|
|
inlineAddVisible = true;
|
|
|
|
const tbody = document.getElementById('mappingsBody');
|
|
const row = document.createElement('tr');
|
|
row.id = 'inlineAddRow';
|
|
row.className = 'table-info';
|
|
row.innerHTML = `
|
|
<td colspan="2">
|
|
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px">
|
|
</td>
|
|
<td colspan="2" class="position-relative">
|
|
<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>
|
|
</td>
|
|
<td>-</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px">
|
|
</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
|
|
</td>
|
|
<td>-</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-success me-1" 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>
|
|
</td>
|
|
`;
|
|
tbody.insertBefore(row, tbody.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;
|
|
}
|
|
|
|
// ── Inline Edit ──────────────────────────────────
|
|
|
|
function editCell(td, sku, codmat, field, currentValue) {
|
|
if (td.querySelector('input')) return;
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'number';
|
|
input.className = 'form-control form-control-sm';
|
|
input.value = currentValue;
|
|
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
|
|
input.style.width = '80px';
|
|
|
|
const originalText = td.textContent;
|
|
td.textContent = '';
|
|
td.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
const save = async () => {
|
|
const newValue = parseFloat(input.value);
|
|
if (isNaN(newValue) || newValue === currentValue) {
|
|
td.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 { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
|
|
} catch (err) { td.textContent = originalText; }
|
|
};
|
|
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') save();
|
|
if (e.key === 'Escape') { td.textContent = originalText; }
|
|
});
|
|
}
|
|
|
|
// ── 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 = ''; }
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|