- Dashboard/Logs: Total column with 2 decimals (order_total) - Order detail modal: totals summary row (items total + order total) - Order detail modal mobile: compact article cards (d-md-none) - Mappings: openEditModal loads all CODMATs for SKU, saveMapping replaces entire set via delete-all + batch POST - Add project-specific team agents: ui-templates, ui-js, ui-verify, backend-api - CLAUDE.md: mandatory preview approval before implementation, fix-loop after verification, server must start via start.sh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
712 lines
31 KiB
JavaScript
712 lines
31 KiB
JavaScript
// ── State ─────────────────────────────────────────
|
|
let dashPage = 1;
|
|
let dashPerPage = 50;
|
|
let dashSortCol = 'order_date';
|
|
let dashSortDir = 'desc';
|
|
let dashSearchTimeout = null;
|
|
let currentQmSku = '';
|
|
let currentQmOrderNumber = '';
|
|
let qmAcTimeout = null;
|
|
|
|
// Sync polling state
|
|
let _pollInterval = null;
|
|
let _lastSyncStatus = null;
|
|
let _lastRunId = null;
|
|
let _currentRunId = null;
|
|
|
|
// ── Init ──────────────────────────────────────────
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadSchedulerStatus();
|
|
loadDashOrders();
|
|
startSyncPolling();
|
|
wireFilterBar();
|
|
});
|
|
|
|
// ── Smart Sync Polling ────────────────────────────
|
|
|
|
function startSyncPolling() {
|
|
if (_pollInterval) clearInterval(_pollInterval);
|
|
_pollInterval = setInterval(pollSyncStatus, 30000);
|
|
pollSyncStatus(); // immediate first call
|
|
}
|
|
|
|
async function pollSyncStatus() {
|
|
try {
|
|
const data = await fetchJSON('/api/sync/status');
|
|
updateSyncPanel(data);
|
|
const isRunning = data.status === 'running';
|
|
const wasRunning = _lastSyncStatus === 'running';
|
|
if (isRunning && !wasRunning) {
|
|
// Switched to running — speed up polling
|
|
clearInterval(_pollInterval);
|
|
_pollInterval = setInterval(pollSyncStatus, 3000);
|
|
} else if (!isRunning && wasRunning) {
|
|
// Sync just completed — slow down and refresh orders
|
|
clearInterval(_pollInterval);
|
|
_pollInterval = setInterval(pollSyncStatus, 30000);
|
|
loadDashOrders();
|
|
}
|
|
_lastSyncStatus = data.status;
|
|
} catch (e) {
|
|
console.warn('Sync status poll failed:', e);
|
|
}
|
|
}
|
|
|
|
function updateSyncPanel(data) {
|
|
const dot = document.getElementById('syncStatusDot');
|
|
const txt = document.getElementById('syncStatusText');
|
|
const progressArea = document.getElementById('syncProgressArea');
|
|
const progressText = document.getElementById('syncProgressText');
|
|
const startBtn = document.getElementById('syncStartBtn');
|
|
|
|
if (dot) {
|
|
dot.className = 'sync-status-dot ' + (data.status || 'idle');
|
|
}
|
|
const statusLabels = { running: 'A ruleaza...', idle: 'Inactiv', completed: 'Finalizat', failed: 'Eroare' };
|
|
if (txt) txt.textContent = statusLabels[data.status] || data.status || 'Inactiv';
|
|
if (startBtn) startBtn.disabled = data.status === 'running';
|
|
|
|
// Track current running sync run_id
|
|
if (data.status === 'running' && data.run_id) {
|
|
_currentRunId = data.run_id;
|
|
} else {
|
|
_currentRunId = null;
|
|
}
|
|
|
|
// Live progress area
|
|
if (progressArea) {
|
|
progressArea.style.display = data.status === 'running' ? 'flex' : 'none';
|
|
}
|
|
if (progressText && data.phase_text) {
|
|
progressText.textContent = data.phase_text;
|
|
}
|
|
|
|
// Last run info
|
|
const lr = data.last_run;
|
|
if (lr) {
|
|
_lastRunId = lr.run_id;
|
|
const d = document.getElementById('lastSyncDate');
|
|
const dur = document.getElementById('lastSyncDuration');
|
|
const cnt = document.getElementById('lastSyncCounts');
|
|
const st = document.getElementById('lastSyncStatus');
|
|
if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014';
|
|
if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014';
|
|
if (cnt) {
|
|
const newImp = lr.new_imported || 0;
|
|
const already = lr.already_imported || 0;
|
|
if (already > 0) {
|
|
cnt.innerHTML = `<span class="dot dot-green me-1"></span>${newImp} noi, ${already} deja <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
|
|
} else {
|
|
cnt.innerHTML = `<span class="dot dot-green me-1"></span>${lr.imported || 0} imp. <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
|
|
}
|
|
}
|
|
if (st) {
|
|
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
|
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wire last-sync-row click → journal (use current running sync if active)
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
|
const targetId = _currentRunId || _lastRunId;
|
|
if (targetId) window.location = '/logs?run=' + targetId;
|
|
});
|
|
document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => {
|
|
const targetId = _currentRunId || _lastRunId;
|
|
if ((e.key === 'Enter' || e.key === ' ') && targetId) {
|
|
window.location = '/logs?run=' + targetId;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Sync Controls ─────────────────────────────────
|
|
|
|
async function startSync() {
|
|
try {
|
|
const res = await fetch('/api/sync/start', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
alert(data.error);
|
|
return;
|
|
}
|
|
// Polling will detect the running state — just speed it up immediately
|
|
pollSyncStatus();
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function stopSync() {
|
|
try {
|
|
await fetch('/api/sync/stop', { method: 'POST' });
|
|
pollSyncStatus();
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function toggleScheduler() {
|
|
const enabled = document.getElementById('schedulerToggle').checked;
|
|
const interval = parseInt(document.getElementById('schedulerInterval').value) || 10;
|
|
try {
|
|
await fetch('/api/sync/schedule', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled, interval_minutes: interval })
|
|
});
|
|
} catch (err) {
|
|
alert('Eroare scheduler: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function updateSchedulerInterval() {
|
|
const enabled = document.getElementById('schedulerToggle').checked;
|
|
if (enabled) {
|
|
await toggleScheduler();
|
|
}
|
|
}
|
|
|
|
async function loadSchedulerStatus() {
|
|
try {
|
|
const res = await fetch('/api/sync/schedule');
|
|
const data = await res.json();
|
|
document.getElementById('schedulerToggle').checked = data.enabled || false;
|
|
if (data.interval_minutes) {
|
|
document.getElementById('schedulerInterval').value = data.interval_minutes;
|
|
}
|
|
} catch (err) {
|
|
console.error('loadSchedulerStatus error:', err);
|
|
}
|
|
}
|
|
|
|
// ── Filter Bar wiring ─────────────────────────────
|
|
|
|
function wireFilterBar() {
|
|
// Period dropdown
|
|
document.getElementById('periodSelect')?.addEventListener('change', function () {
|
|
const cr = document.getElementById('customRangeInputs');
|
|
if (this.value === 'custom') {
|
|
cr?.classList.add('visible');
|
|
} else {
|
|
cr?.classList.remove('visible');
|
|
dashPage = 1;
|
|
loadDashOrders();
|
|
}
|
|
});
|
|
|
|
// Custom range inputs
|
|
['periodStart', 'periodEnd'].forEach(id => {
|
|
document.getElementById(id)?.addEventListener('change', () => {
|
|
const s = document.getElementById('periodStart')?.value;
|
|
const e = document.getElementById('periodEnd')?.value;
|
|
if (s && e) { dashPage = 1; loadDashOrders(); }
|
|
});
|
|
});
|
|
|
|
// Status pills
|
|
document.querySelectorAll('.filter-pill[data-status]').forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
dashPage = 1;
|
|
loadDashOrders();
|
|
});
|
|
});
|
|
|
|
// Search — 300ms debounce
|
|
document.getElementById('orderSearch')?.addEventListener('input', () => {
|
|
clearTimeout(dashSearchTimeout);
|
|
dashSearchTimeout = setTimeout(() => {
|
|
dashPage = 1;
|
|
loadDashOrders();
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
// ── Dashboard Orders Table ────────────────────────
|
|
|
|
function dashSortBy(col) {
|
|
if (dashSortCol === col) {
|
|
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
dashSortCol = col;
|
|
dashSortDir = 'asc';
|
|
}
|
|
document.querySelectorAll('.sort-icon').forEach(span => {
|
|
const c = span.dataset.col;
|
|
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
|
|
});
|
|
dashPage = 1;
|
|
loadDashOrders();
|
|
}
|
|
|
|
async function loadDashOrders() {
|
|
const periodVal = document.getElementById('periodSelect')?.value || '7';
|
|
const params = new URLSearchParams();
|
|
|
|
if (periodVal === 'custom') {
|
|
const s = document.getElementById('periodStart')?.value;
|
|
const e = document.getElementById('periodEnd')?.value;
|
|
if (s && e) {
|
|
params.set('period_start', s);
|
|
params.set('period_end', e);
|
|
params.set('period_days', '0');
|
|
}
|
|
} else {
|
|
params.set('period_days', periodVal);
|
|
}
|
|
|
|
const activeStatus = document.querySelector('.filter-pill.active')?.dataset.status;
|
|
if (activeStatus && activeStatus !== 'all') params.set('status', activeStatus);
|
|
|
|
const search = document.getElementById('orderSearch')?.value?.trim();
|
|
if (search) params.set('search', search);
|
|
|
|
params.set('page', dashPage);
|
|
params.set('per_page', dashPerPage);
|
|
params.set('sort_by', dashSortCol);
|
|
params.set('sort_dir', dashSortDir);
|
|
|
|
try {
|
|
const res = await fetch(`/api/dashboard/orders?${params}`);
|
|
const data = await res.json();
|
|
|
|
// Update filter-pill badge counts
|
|
const c = data.counts || {};
|
|
const el = (id) => document.getElementById(id);
|
|
if (el('cntAll')) el('cntAll').textContent = c.total || 0;
|
|
if (el('cntImp')) el('cntImp').textContent = c.imported_all || c.imported || 0;
|
|
if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0;
|
|
if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0;
|
|
if (el('cntNef')) el('cntNef').textContent = c.uninvoiced || c.nefacturate || 0;
|
|
|
|
const tbody = document.getElementById('dashOrdersBody');
|
|
const orders = data.orders || [];
|
|
|
|
if (orders.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = orders.map(o => {
|
|
const dateStr = fmtDate(o.order_date);
|
|
const statusBadge = orderStatusBadge(o.status);
|
|
|
|
// Invoice info
|
|
let invoiceBadge = '';
|
|
if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') {
|
|
invoiceBadge = '<span class="text-muted">-</span>';
|
|
} else if (o.invoice && o.invoice.facturat) {
|
|
invoiceBadge = `<span style="color:#16a34a;font-weight:500">Facturat</span>`;
|
|
if (o.invoice.serie_act || o.invoice.numar_act) {
|
|
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
|
|
}
|
|
} else {
|
|
invoiceBadge = `<span style="color:#dc2626">Nefacturat</span>`;
|
|
}
|
|
|
|
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
|
|
|
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
|
<td><code>${esc(o.order_number)}</code></td>
|
|
<td>${dateStr}</td>
|
|
${renderClientCell(o)}
|
|
<td>${o.items_count || 0}</td>
|
|
<td class="text-nowrap">${statusDot(o.status)} ${statusLabelText(o.status)}</td>
|
|
<td>${o.id_comanda || '-'}</td>
|
|
<td>${invoiceBadge}</td>
|
|
<td>${orderTotal}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Mobile flat rows
|
|
const mobileList = document.getElementById('dashMobileList');
|
|
if (mobileList) {
|
|
if (orders.length === 0) {
|
|
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
|
} else {
|
|
mobileList.innerHTML = orders.map(o => {
|
|
const d = o.order_date || '';
|
|
let dateFmt = '-';
|
|
if (d.length >= 10) {
|
|
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
|
|
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
|
|
}
|
|
const name = o.shipping_name || o.customer_name || o.billing_name || '\u2014';
|
|
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
|
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
|
${statusDot(o.status)}
|
|
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
|
<span class="grow truncate">${esc(name)}</span>
|
|
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + totalStr : ''}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// Mobile segmented control
|
|
renderMobileSegmented('dashMobileSeg', [
|
|
{ label: 'Toate', count: c.total || 0, value: 'all', active: (activeStatus || 'all') === 'all', colorClass: 'fc-neutral' },
|
|
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: 'IMPORTED', active: activeStatus === 'IMPORTED', colorClass: 'fc-green' },
|
|
{ label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
|
|
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
|
|
{ label: 'Nefact.', count: c.uninvoiced || c.nefacturate || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-neutral' }
|
|
], (val) => {
|
|
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
|
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
|
|
if (pill) pill.classList.add('active');
|
|
dashPage = 1;
|
|
loadDashOrders();
|
|
});
|
|
|
|
// Pagination
|
|
const pag = data.pagination || {};
|
|
const totalPages = pag.total_pages || data.pages || 1;
|
|
const totalOrders = (data.counts || {}).total || data.total || 0;
|
|
|
|
const pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
|
|
const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
|
|
const pagDiv = document.getElementById('dashPagination');
|
|
if (pagDiv) pagDiv.innerHTML = pagHtml;
|
|
const pagDivTop = document.getElementById('dashPaginationTop');
|
|
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
|
|
|
|
// Update sort icons
|
|
document.querySelectorAll('.sort-icon').forEach(span => {
|
|
const c = span.dataset.col;
|
|
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
|
|
});
|
|
} catch (err) {
|
|
document.getElementById('dashOrdersBody').innerHTML =
|
|
`<tr><td colspan="8" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function dashGoPage(p) {
|
|
dashPage = p;
|
|
loadDashOrders();
|
|
}
|
|
|
|
function dashChangePerPage(val) {
|
|
dashPerPage = parseInt(val) || 50;
|
|
dashPage = 1;
|
|
loadDashOrders();
|
|
}
|
|
|
|
// ── Client cell with Cont tooltip (Task F4) ───────
|
|
|
|
function renderClientCell(order) {
|
|
const shipping = (order.shipping_name || order.customer_name || '').trim();
|
|
const billing = (order.billing_name || '').trim();
|
|
const isDiff = order.is_different_person && billing && shipping !== billing;
|
|
if (isDiff) {
|
|
return `<td class="tooltip-cont" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)} <sup style="color:#6b7280;font-size:0.65rem">▲</sup></td>`;
|
|
}
|
|
return `<td>${escHtml(shipping || billing || '\u2014')}</td>`;
|
|
}
|
|
|
|
// ── Helper functions ──────────────────────────────
|
|
|
|
async function fetchJSON(url) {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
function escHtml(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Alias kept for backward compat with inline handlers in modal
|
|
function esc(s) { return escHtml(s); }
|
|
|
|
|
|
function statusLabelText(status) {
|
|
switch ((status || '').toUpperCase()) {
|
|
case 'IMPORTED': return 'Importat';
|
|
case 'ALREADY_IMPORTED': return 'Deja imp.';
|
|
case 'SKIPPED': return 'Omis';
|
|
case 'ERROR': return 'Eroare';
|
|
default: return esc(status);
|
|
}
|
|
}
|
|
|
|
function orderStatusBadge(status) {
|
|
switch ((status || '').toUpperCase()) {
|
|
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
|
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
|
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
|
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
|
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
|
}
|
|
}
|
|
|
|
function renderCodmatCell(item) {
|
|
if (!item.codmat_details || item.codmat_details.length === 0) {
|
|
return `<code>${esc(item.codmat || '-')}</code>`;
|
|
}
|
|
if (item.codmat_details.length === 1) {
|
|
const d = item.codmat_details[0];
|
|
return `<code>${esc(d.codmat)}</code>`;
|
|
}
|
|
return item.codmat_details.map(d =>
|
|
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
|
|
).join('');
|
|
}
|
|
|
|
// ── Order Detail Modal ────────────────────────────
|
|
|
|
async function openDashOrderDetail(orderNumber) {
|
|
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
|
document.getElementById('detailCustomer').textContent = '...';
|
|
document.getElementById('detailDate').textContent = '';
|
|
document.getElementById('detailStatus').innerHTML = '';
|
|
document.getElementById('detailIdComanda').textContent = '-';
|
|
document.getElementById('detailIdPartener').textContent = '-';
|
|
document.getElementById('detailIdAdresaFact').textContent = '-';
|
|
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
|
document.getElementById('detailError').style.display = 'none';
|
|
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
|
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
|
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
|
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
if (mobileContainer) mobileContainer.innerHTML = '';
|
|
|
|
const modalEl = document.getElementById('orderDetailModal');
|
|
const existing = bootstrap.Modal.getInstance(modalEl);
|
|
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
|
|
|
|
try {
|
|
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
document.getElementById('detailError').textContent = data.error;
|
|
document.getElementById('detailError').style.display = '';
|
|
return;
|
|
}
|
|
|
|
const order = data.order || {};
|
|
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
|
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
|
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
|
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
|
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
|
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
|
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
|
|
|
if (order.error_message) {
|
|
document.getElementById('detailError').textContent = order.error_message;
|
|
document.getElementById('detailError').style.display = '';
|
|
}
|
|
|
|
const items = data.items || [];
|
|
if (items.length === 0) {
|
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>';
|
|
return;
|
|
}
|
|
|
|
// Update totals row
|
|
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
|
|
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
|
|
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
|
|
|
|
// Mobile article cards
|
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
if (mobileContainer) {
|
|
mobileContainer.innerHTML = items.map(item => {
|
|
let statusLabel = '';
|
|
switch (item.mapping_status) {
|
|
case 'mapped': statusLabel = '<span class="badge bg-success">Mapat</span>'; break;
|
|
case 'direct': statusLabel = '<span class="badge bg-info">Direct</span>'; break;
|
|
case 'missing': statusLabel = '<span class="badge bg-warning">Lipsa</span>'; break;
|
|
default: statusLabel = '<span class="badge bg-secondary">?</span>';
|
|
}
|
|
const codmat = item.codmat || '-';
|
|
return `<div class="detail-item-card">
|
|
<div class="card-sku">${esc(item.sku)}</div>
|
|
<div class="card-name">${esc(item.product_name || '-')}</div>
|
|
<div class="card-details">
|
|
<span>x${item.quantity || 0}</span>
|
|
<span>${item.price != null ? Number(item.price).toFixed(2) : '-'} lei</span>
|
|
<span><code>${esc(codmat)}</code></span>
|
|
<span>${statusLabel}</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
|
let statusBadge;
|
|
switch (item.mapping_status) {
|
|
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
|
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
|
case 'missing': statusBadge = '<span class="badge bg-warning">Lipsa</span>'; break;
|
|
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
|
}
|
|
|
|
const action = item.mapping_status === 'missing'
|
|
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
|
: '';
|
|
|
|
return `<tr>
|
|
<td><code>${esc(item.sku)}</code></td>
|
|
<td>${esc(item.product_name || '-')}</td>
|
|
<td>${item.quantity || 0}</td>
|
|
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
|
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
|
<td>${renderCodmatCell(item)}</td>
|
|
<td>${statusBadge}</td>
|
|
<td>${action}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
document.getElementById('detailError').textContent = err.message;
|
|
document.getElementById('detailError').style.display = '';
|
|
}
|
|
}
|
|
|
|
// ── Quick Map Modal ───────────────────────────────
|
|
|
|
function openQuickMap(sku, productName, orderNumber) {
|
|
currentQmSku = sku;
|
|
currentQmOrderNumber = orderNumber;
|
|
document.getElementById('qmSku').textContent = sku;
|
|
document.getElementById('qmProductName').textContent = productName || '-';
|
|
document.getElementById('qmPctWarning').style.display = 'none';
|
|
|
|
const container = document.getElementById('qmCodmatLines');
|
|
container.innerHTML = '';
|
|
addQmCodmatLine();
|
|
|
|
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
|
|
}
|
|
|
|
function addQmCodmatLine() {
|
|
const container = document.getElementById('qmCodmatLines');
|
|
const idx = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'border rounded p-2 mb-2 qm-line';
|
|
div.innerHTML = `
|
|
<div class="mb-2 position-relative">
|
|
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
|
|
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
|
|
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
|
|
<small class="text-muted qm-selected"></small>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-5">
|
|
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
|
|
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
|
|
</div>
|
|
<div class="col-5">
|
|
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
|
|
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
|
|
</div>
|
|
<div class="col-2 d-flex align-items-end">
|
|
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
|
|
</div>
|
|
</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('.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;
|
|
const procent = parseFloat(line.querySelector('.qm-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('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
|
|
document.getElementById('qmPctWarning').style.display = '';
|
|
return;
|
|
}
|
|
}
|
|
document.getElementById('qmPctWarning').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: currentQmSku, 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: currentQmSku, mappings })
|
|
});
|
|
}
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
|
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
|
|
loadDashOrders();
|
|
} else {
|
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + err.message);
|
|
}
|
|
}
|