Files
gomag-vending/api/app/templates/missing_skus.html
Claude Agent 680f670037 feat(ui): mobile UI polish with segmented controls and responsive navbar
- Replace filter pills with btn-group segmented controls on mobile (all pages)
- Add renderMobileSegmented() shared utility with colored count badges
- Compact sync card and logs run selector on mobile
- Unified flat-row format: dot + date + name + count (0.875rem throughout)
- Responsive navbar with short labels on mobile (Acasa/Mapari/Lipsa/Jurnale)
- Vertical dots icon (bi-three-dots-vertical) without dropdown caret
- Shorter "Mapare" button text on mobile, Re-scan in context menu
- Top pagination on logs page, hide per-page selector on mobile
- Cache-bust static assets to v=5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:24 +00:00

396 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
{% block nav_missing %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">SKU-uri Lipsa</h4>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV
</button>
<!-- Mobile ⋯ dropdown -->
<div class="dropdown d-md-none">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="document.getElementById('rescanBtn').click();return false"><i class="bi bi-arrow-clockwise me-1"></i> Re-scan</a></li>
<li><a class="dropdown-item" href="#" onclick="exportMissingCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
</ul>
</div>
</div>
</div>
<!-- Unified filter bar -->
<div class="filter-bar" id="skusFilterBar">
<button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
</button>
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
</button>
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
</button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2 d-none d-md-inline-flex">&#8635; Re-scan</button>
<span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
<span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span>
</span>
</div>
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
<!-- Result banner -->
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
<div id="skusPagTop" class="pag-strip mb-2"></div>
<div class="card">
<div class="card-body p-0">
<div id="missingMobileList" class="mobile-list"></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Status</th>
<th>SKU</th>
<th>Produs</th>
<th>Actiune</th>
</tr>
</thead>
<tbody id="missingBody">
<tr><td colspan="4" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="mapSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
</div>
<div id="mapCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
<button type="button" class="btn btn-primary" onclick="saveQuickMap()">Salveaza</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentMapSku = '';
let mapAcTimeout = null;
let currentPage = 1;
let skuStatusFilter = 'unresolved';
let missingPerPage = 20;
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
// ── Filter pills ──────────────────────────────────
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
skuStatusFilter = this.dataset.skuStatus;
currentPage = 1;
loadMissingSkus();
});
});
// ── Search with debounce ─────────────────────────
let skuSearchTimer = null;
document.getElementById('skuSearch')?.addEventListener('input', function() {
clearTimeout(skuSearchTimer);
skuSearchTimer = setTimeout(() => { currentPage = 1; loadMissingSkus(); }, 300);
});
// ── Rescan ────────────────────────────────────────
document.getElementById('rescanBtn')?.addEventListener('click', async function() {
this.disabled = true;
const prog = document.getElementById('rescanProgress');
const result = document.getElementById('rescanResult');
const progText = document.getElementById('rescanProgressText');
if (prog) { prog.style.display = 'flex'; }
if (result) result.style.display = 'none';
try {
const data = await fetch('/api/validate/scan', { method: 'POST' }).then(r => r.json());
if (progText) progText.textContent = 'Gata.';
if (result) {
result.innerHTML = `&#10003; ${data.total_skus_scanned || 0} scanate &nbsp;|&nbsp; ${data.new_missing || 0} noi lipsa &nbsp;|&nbsp; ${data.auto_resolved || 0} rezolvate`;
result.style.display = 'block';
}
loadMissingSkus();
} catch(e) {
if (progText) progText.textContent = 'Eroare.';
} finally {
this.disabled = false;
setTimeout(() => { if (prog) prog.style.display = 'none'; }, 2500);
}
});
document.addEventListener('DOMContentLoaded', () => {
loadMissingSkus();
});
function resolvedParamFor(statusFilter) {
if (statusFilter === 'resolved') return 1;
if (statusFilter === 'all') return -1;
return 0; // unresolved (default)
}
function loadMissingSkus(page) {
currentPage = page || currentPage;
const params = new URLSearchParams();
const resolvedVal = resolvedParamFor(skuStatusFilter);
params.set('resolved', resolvedVal);
params.set('page', currentPage);
params.set('per_page', missingPerPage);
const search = document.getElementById('skuSearch')?.value?.trim();
if (search) params.set('search', search);
fetch('/api/validate/missing-skus?' + params.toString())
.then(r => r.json())
.then(data => {
const c = data.counts || {};
const el = id => document.getElementById(id);
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0;
// Mobile segmented control
renderMobileSegmented('skusMobileSeg', [
{ label: 'Nerez.', count: c.unresolved || 0, value: 'unresolved', active: skuStatusFilter === 'unresolved', colorClass: 'fc-yellow' },
{ label: 'Rez.', count: c.resolved || 0, value: 'resolved', active: skuStatusFilter === 'resolved', colorClass: 'fc-green' },
{ label: 'Toate', count: c.total || 0, value: 'all', active: skuStatusFilter === 'all', colorClass: 'fc-neutral' }
], (val) => {
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-sku-status="${val}"]`);
if (pill) pill.classList.add('active');
skuStatusFilter = val;
currentPage = 1;
loadMissingSkus();
});
renderMissingSkusTable(data.skus || data.missing_skus || [], data);
renderPagination(data);
})
.catch(err => {
document.getElementById('missingBody').innerHTML =
`<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
});
}
// Keep backward compat alias
function loadMissing(page) { loadMissingSkus(page); }
function renderMissingSkusTable(skus, data) {
const tbody = document.getElementById('missingBody');
const mobileList = document.getElementById('missingMobileList');
if (!skus || skus.length === 0) {
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted py-4">${msg}</td></tr>`;
if (mobileList) mobileList.innerHTML = `<div class="flat-row text-muted py-3 justify-content-center">${msg}</div>`;
return;
}
tbody.innerHTML = skus.map(s => {
const trAttrs = !s.resolved
? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
: '';
return `<tr${trAttrs}>
<td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
<td><code>${esc(s.sku)}</code></td>
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td>
${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
<i class="bi bi-link-45deg"></i>
</a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
</td>
</tr>`;
}).join('');
if (mobileList) {
mobileList.innerHTML = skus.map(s => {
const actionHtml = !s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
const flatRowAttrs = !s.resolved
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
: '';
return `<div class="flat-row"${flatRowAttrs}>
${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}
<code class="me-1 text-nowrap">${esc(s.sku)}</code>
<span class="grow truncate">${esc(s.product_name || '-')}</span>
${actionHtml}
</div>`;
}).join('');
}
}
function renderPagination(data) {
const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
const top = document.getElementById('skusPagTop');
const bot = document.getElementById('skusPagBottom');
if (top) top.innerHTML = pagHtml;
if (bot) bot.innerHTML = pagHtml;
}
// ── Multi-CODMAT Map Modal ───────────────────────
function openMapModal(sku, productName) {
currentMapSku = sku;
document.getElementById('mapSku').textContent = sku;
document.getElementById('mapProductName').textContent = productName || '-';
document.getElementById('mapPctWarning').style.display = 'none';
const container = document.getElementById('mapCodmatLines');
container.innerHTML = '';
addMapCodmatLine();
new bootstrap.Modal(document.getElementById('mapModal')).show();
}
function addMapCodmatLine() {
const container = document.getElementById('mapCodmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 mc-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 mc-codmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
<small class="text-muted mc-selected"></small>
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm mc-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 mc-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('.mc-line').remove()"><i class="bi bi-x"></i></button>` : '<div style="width:31px"></div>'}
</div>
</div>
`;
container.appendChild(div);
const input = div.querySelector('.mc-codmat');
const dropdown = div.querySelector('.mc-ac-dropdown');
const selected = div.querySelector('.mc-selected');
input.addEventListener('input', () => {
clearTimeout(mapAcTimeout);
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function mcAutocomplete(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="mcSelectArticle(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 mcSelectArticle(el, codmat, label) {
const line = el.closest('.mc-line');
line.querySelector('.mc-codmat').value = codmat;
line.querySelector('.mc-selected').textContent = label;
line.querySelector('.mc-ac-dropdown').classList.add('d-none');
}
async function saveQuickMap() {
const lines = document.querySelectorAll('.mc-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.mc-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.mc-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('mapPctWarning').style.display = '';
return;
}
}
document.getElementById('mapPctWarning').style.display = 'none';
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, 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: currentMapSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissingSkus(currentPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
function exportMissingCsv() {
window.location.href = '/api/validate/missing-skus-csv';
}
</script>
{% endblock %}