Files
gomag-vending/api/app/static/js/dashboard.js
Claude Agent bb6f3a3b87 feat(sync): /api/sync/health endpoint + dashboard health pill + MALFORMED UI
Backend:
- GET /api/sync/health returns {last_sync_at, last_sync_status,
  last_halt_reason, recent_phase_failures, escalation_phase, is_healthy}.
  healthy when last run was completed (or none yet), no phase has
  tripped the 3-in-a-row escalation, and recent failures <= 1.
- Dashboard + run-level endpoints include `malformed` count so the
  Defecte pill can render.

Frontend:
- Health pill in .sync-card-controls with three states — healthy
  (success green, check icon), warning (amber, triangle), escalated
  (error red, x-octagon + glow). Tooltip exposes the halt reason and
  the top phases with recent failures.
- Status-dot + badge add MALFORMED treatment via --compare orange,
  distinct from ERROR red. DESIGN.md notes the diagnostic rationale
  (ERROR = runtime, MALFORMED = payload source issue).
- Defecte filter pill on dashboard + logs pages. Mobile segmented
  control includes Defecte count. Counts wired to the malformed key.
- startSync() shows a native confirm modal when state is
  halted_escalation — operator override still possible, not silenced.
- ORDER_STATUS.MALFORMED mirror added to shared.js.
- Cache-bust: style.css v46, shared.js v47, dashboard.js v52,
  logs.js v16.

5 endpoint tests cover empty state, completed, failed, escalated,
single-failure warning. Full CI: 257 unit + 33 e2e green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:14:46 +00:00

725 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── State ─────────────────────────────────────────
let dashPage = 1;
let dashPerPage = 50;
let dashSortCol = 'order_date';
let dashSortDir = 'desc';
let dashSearchTimeout = null;
// Sync polling state
let _pollInterval = null;
let _lastSyncStatus = null;
let _lastRunId = null;
let _currentRunId = null;
let _pollIntervalMs = 5000; // default, overridden from settings
let _knownLastRunId = null; // track last_run.run_id to detect missed syncs
let _schedulerLoading = false; // prevent onchange during programmatic load
// ── Init ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
await initPollInterval();
loadSchedulerStatus();
loadDashOrders();
startSyncPolling();
wireFilterBar();
checkFirstTime();
});
async function initPollInterval() {
try {
const data = await fetchJSON('/api/settings');
const sec = parseInt(data.dashboard_poll_seconds) || 5;
_pollIntervalMs = sec * 1000;
} catch(e) {}
}
// ── Smart Sync Polling ────────────────────────────
function startSyncPolling() {
if (_pollInterval) clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
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';
// Detect missed sync completions via last_run.run_id change
const newLastRunId = data.last_run?.run_id || null;
const missedSync = !isRunning && !wasRunning && _knownLastRunId && newLastRunId && newLastRunId !== _knownLastRunId;
_knownLastRunId = newLastRunId;
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, _pollIntervalMs);
loadDashOrders();
} else if (missedSync) {
// Sync completed while we weren't watching (e.g. auto-sync) — refresh orders
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 &nbsp; <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise &nbsp; <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. &nbsp; <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise &nbsp; <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' ? 'var(--success)' : 'var(--error)';
}
}
}
async function checkFirstTime() {
const welcomeEl = document.getElementById('welcomeCard');
if (!welcomeEl) return;
try {
const data = await fetchJSON('/api/sync/status');
if (!data.last_run) {
welcomeEl.innerHTML = `<div class="welcome-card">
<h5 style="font-family:var(--font-display);margin:0 0 8px">Bine ai venit!</h5>
<p class="text-muted mb-2" style="font-size:0.875rem">Configureaza si ruleaza primul sync:</p>
<div class="welcome-steps">
<span class="welcome-step"><b>1.</b> <a href="${window.ROOT_PATH||''}/settings">Verifica Settings</a></span>
<span class="welcome-step"><b>2.</b> Apasa "Start Sync"</span>
<span class="welcome-step"><b>3.</b> <a href="${window.ROOT_PATH||''}/missing-skus">Mapeaza SKU-urile lipsa</a></span>
</div>
</div>`;
welcomeEl.style.display = '';
} else {
welcomeEl.style.display = 'none';
}
} catch(e) { welcomeEl.style.display = 'none'; }
}
// 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 = (window.ROOT_PATH || '') + '/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 Health pill ─────────────────────────────
let _lastHealth = null;
async function pollSyncHealth() {
try {
const data = await fetchJSON('/api/sync/health');
_lastHealth = data;
renderHealthPill(data);
} catch (e) { /* fail-soft: keep last state */ }
}
function renderHealthPill(h) {
const pill = document.getElementById('syncHealthPill');
if (!pill) return;
const icon = pill.querySelector('i');
const label = pill.querySelector('.health-pill-label');
let state = 'healthy', iconCls = 'bi-check-circle-fill', text = 'Sanatos', tooltip;
const recent = h.recent_phase_failures || {};
const recentCount = Object.values(recent).reduce((a, b) => a + (b || 0), 0);
if (h.escalation_phase || h.last_sync_status === 'halted_escalation') {
state = 'escalated';
iconCls = 'bi-x-octagon-fill';
text = 'Blocat';
tooltip = `Blocat — faza "${h.escalation_phase || '?'}" a esuat 3 sync-uri consecutive.\n`
+ `Ultima eroare: ${h.last_halt_reason || '—'}\n`
+ `Click Start Sync pentru override manual.`;
} else if (h.last_sync_status === 'failed' || recentCount > 0) {
state = 'warning';
iconCls = 'bi-exclamation-triangle-fill';
text = 'Atentie';
const topPhases = Object.entries(recent).slice(0, 3)
.map(([p, c]) => `${p} (${c} of last 3)`).join(', ');
tooltip = `Atentie — ${topPhases || 'sync anterior esuat'}`
+ (h.last_halt_reason ? `\nLast error: ${h.last_halt_reason}` : '');
} else {
const lastAt = h.last_sync_at ? h.last_sync_at.replace('T', ' ').slice(5, 16) : 'nicio rulare';
tooltip = `Sanatos — ultimul sync: ${lastAt}`;
}
pill.className = 'health-pill ' + state;
pill.setAttribute('aria-label', `Sync: ${text}`);
pill.title = tooltip;
if (icon) icon.className = 'bi ' + iconCls;
if (label) label.textContent = text;
}
function startHealthPolling() {
pollSyncHealth();
setInterval(pollSyncHealth, 10000);
}
document.addEventListener('DOMContentLoaded', startHealthPolling);
// ── Sync Controls ─────────────────────────────────
async function startSync() {
// Escalation override — confirm before overriding the auto-halt
if (_lastHealth && (_lastHealth.escalation_phase
|| _lastHealth.last_sync_status === 'halted_escalation')) {
const phase = _lastHealth.escalation_phase || '?';
const reason = _lastHealth.last_halt_reason || '(unknown)';
const msg = `⚠ Sync blocat automat\n\n`
+ `Faza "${phase}" a esuat in ultimele 3 sync-uri consecutive.\n`
+ `Ultima eroare: ${reason}\n\n`
+ `Repornesti oricum?`;
if (!confirm(msg)) return;
}
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();
pollSyncHealth();
} 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() {
if (_schedulerLoading) return; // ignore programmatic changes during load
await toggleScheduler(); // always save interval (even when disabled)
}
async function loadSchedulerStatus() {
_schedulerLoading = true;
try {
const res = await fetch('/api/sync/schedule');
const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false;
document.getElementById('schedulerInterval').value = data.interval_minutes || 10;
} catch (err) {
console.error('loadSchedulerStatus error:', err);
} finally {
_schedulerLoading = false;
}
}
// ── Filter Bar wiring ─────────────────────────────
function wireFilterBar() {
// Period preset buttons
document.querySelectorAll('.preset-btn[data-days]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
const days = this.dataset.days;
const cr = document.getElementById('customRangeInputs');
if (days === '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 activePreset = document.querySelector('.preset-btn.active');
const periodVal = activePreset?.dataset.days || '3';
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('cntFact')) el('cntFact').textContent = c.facturate || 0;
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
if (el('cntMal')) el('cntMal').textContent = c.malformed || 0;
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
// Attention card
const attnEl = document.getElementById('attentionCard');
if (attnEl) {
const errors = c.error || 0;
const unmapped = c.unresolved_skus || 0;
const nefact = c.nefacturate || 0;
const diffs = c.diffs || 0;
const incompleteAddr = c.incomplete_addresses || 0;
const partnerMismatches = c.partner_mismatches || 0;
if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0 && diffs === 0 && partnerMismatches === 0) {
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
} else {
let items = [];
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
if (c.incomplete_addresses > 0) items.push(`<span class="attention-item attention-warning"><i class="bi bi-geo-alt"></i> ${c.incomplete_addresses} adrese incomplete</span>`);
if (diffs > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=DIFFS]')?.click()"><i class="bi bi-exclamation-diamond"></i> ${diffs} diferente ANAF</span>`);
if (partnerMismatches > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=DIFFS]')?.click()"><i class="bi bi-people"></i> ${partnerMismatches} partener schimbat</span>`);
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
}
}
const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date);
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
<td>${statusDot(o.status)}</td>
<td class="text-center">${invoiceDot(o)}</td>
<td class="text-nowrap">${dateStr}</td>
${renderClientCell(o)}
<td><code>${esc(o.order_number)}</code>${diffDots(o)}</td>
<td>${o.items_count || 0}</td>
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
<td class="kebab-dropdown" onclick="event.stopPropagation()">${(o.status === ORDER_STATUS.IMPORTED || o.status === ORDER_STATUS.ALREADY_IMPORTED) && !(o.invoice && o.invoice.facturat) ? '<div class="dropdown"><button class="btn btn-sm border-0" aria-label="Actiuni comanda" data-bs-toggle="dropdown"><i class="bi bi-three-dots-vertical"></i></button><ul class="dropdown-menu dropdown-menu-end"><li><button class="dropdown-item" onclick="dashResyncOrder(\'' + esc(o.order_number) + '\', this)"><i class="bi bi-arrow-repeat me-2"></i>Resync</button></li><li><button class="dropdown-item text-danger" onclick="dashDeleteOrder(\'' + esc(o.order_number) + '\', this)"><i class="bi bi-trash me-2"></i>Sterge din ROA</button></li></ul></div>' : ''}</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.customer_name || o.shipping_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:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(name)}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + diffDots(o, true) + '<strong>' + totalStr + '</strong>' : ''}</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: ORDER_STATUS.IMPORTED, active: activeStatus === ORDER_STATUS.IMPORTED, colorClass: 'fc-green' },
{ label: 'Omise', count: c.skipped || 0, value: ORDER_STATUS.SKIPPED, active: activeStatus === ORDER_STATUS.SKIPPED, colorClass: 'fc-yellow' },
{ label: 'Erori', count: c.error || c.errors || 0, value: ORDER_STATUS.ERROR, active: activeStatus === ORDER_STATUS.ERROR, colorClass: 'fc-red' },
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
{ label: 'Anulate', count: c.cancelled || 0, value: ORDER_STATUS.CANCELLED, active: activeStatus === ORDER_STATUS.CANCELLED, colorClass: 'fc-dark' },
{ label: 'Def.', count: c.malformed || 0, value: ORDER_STATUS.MALFORMED, active: activeStatus === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' },
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
], (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="10" 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 display = (order.customer_name || order.shipping_name || '').trim();
const billing = (order.billing_name || '').trim();
const shipping = (order.shipping_name || '').trim();
// PJ: invoice party (company = display) differs from shipping person
// PF ramburs: invoice party = shipping, but billing person differs from shipping
const isPJDiff = display && shipping && display !== shipping;
const isPFDiff = !isPJDiff && billing && shipping && billing !== shipping;
if (isPJDiff || isPFDiff) {
const facturat = isPJDiff ? display : billing;
const tip = `Facturat: ${escHtml(facturat)} · Livrare: ${escHtml(shipping)}`;
return `<td class="tooltip-cont fw-bold" data-tooltip="${tip}">${escHtml(display)}&nbsp;<sup class="client-diff-indicator" aria-label="${tip}" title="${tip}">&#9650;</sup></td>`;
}
return `<td class="fw-bold">${escHtml(display || 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function statusLabelText(status) {
switch ((status || '').toUpperCase()) {
case ORDER_STATUS.IMPORTED: return 'Importat';
case ORDER_STATUS.ALREADY_IMPORTED: return 'Deja imp.';
case ORDER_STATUS.SKIPPED: return 'Omis';
case ORDER_STATUS.ERROR: return 'Eroare';
default: return esc(status);
}
}
function diffDots(o, mobile) {
const sz = mobile ? 6 : 7;
const ml = mobile ? 'margin-right:2px' : 'margin-left:3px';
let d = '';
const s = `display:inline-block;width:${sz}px;height:${sz}px;border-radius:50%;${ml};vertical-align:middle`;
if (o.anaf_cod_fiscal_adjusted===1 ||
(o.cod_fiscal_gomag && o.anaf_platitor_tva!==null && o.anaf_cod_fiscal_adjusted!==1 &&
(/^RO/i.test(o.cod_fiscal_gomag)!==(o.anaf_platitor_tva===1))))
d += `<span style="${s};background:var(--error)" title="CUI/TVA ANAF"></span>`;
if (o.anaf_denumire_mismatch===1)
d += `<span style="${s};background:var(--compare)" title="Denumire ANAF"></span>`;
if (o.address_mismatch===1)
d += `<span style="${s};background:var(--info)" title="Adresa diferita"></span>`;
if (o.partner_mismatch===1)
d += `<span style="${s};background:var(--warning)" title="Partener schimbat"></span>`;
return d;
}
function invoiceDot(order) {
if (order.status !== ORDER_STATUS.IMPORTED && order.status !== ORDER_STATUS.ALREADY_IMPORTED) return '';
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" style="box-shadow:none" title="Facturat"></span>';
return '<span class="dot dot-red" style="box-shadow:none" title="Nefacturat"></span>';
}
// ── Refresh Invoices ──────────────────────────────
async function refreshInvoices() {
const btn = document.getElementById('btnRefreshInvoices');
const btnM = document.getElementById('btnRefreshInvoicesMobile');
if (btn) { btn.disabled = true; btn.textContent = '⟳ Se verifica...'; }
if (btnM) { btnM.disabled = true; }
try {
const res = await fetch('/api/dashboard/refresh-invoices', { method: 'POST' });
const data = await res.json();
if (data.error) {
alert('Eroare: ' + data.error);
} else {
loadDashOrders();
}
} catch (err) {
alert('Eroare: ' + err.message);
} finally {
if (btn) { btn.disabled = false; btn.textContent = '↻ Facturi'; }
if (btnM) { btnM.disabled = false; }
}
}
// ── Order Detail Modal ────────────────────────────
async function refreshOrderAddress(orderNumber) {
if (!orderNumber) return;
const btn = document.getElementById('refreshAddrBtn');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try {
const res = await fetch(`/api/orders/${orderNumber}/refresh-address`, {method: 'POST'});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
showToast('Eroare refresh adresă: ' + (err.detail || res.status), 'danger');
return;
}
showToast('Adresă actualizată din Oracle', 'success');
renderOrderDetailModal(orderNumber, {onQuickMap: openDashQuickMap});
} catch (e) {
showToast('Eroare conexiune', 'danger');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>'; }
}
}
function openDashOrderDetail(orderNumber) {
_sharedModalQuickMapFn = openDashQuickMap;
renderOrderDetailModal(orderNumber, {
onQuickMap: openDashQuickMap,
onStatusChange: loadDashOrders,
onAfterRender: function() { /* nothing extra needed */ }
});
}
// ── Quick Map Modal (uses shared openQuickMap) ───
function openDashQuickMap(sku, productName, orderNumber, itemIdx) {
const item = (window._detailItems || [])[itemIdx];
const details = item?.codmat_details;
const isDirect = details?.length === 1 && details[0].direct === true;
openQuickMap({
sku,
productName,
isDirect,
directInfo: isDirect ? { codmat: details[0].codmat, denumire: details[0].denumire } : null,
prefill: (!isDirect && details?.length) ? details.map(d => ({ codmat: d.codmat, cantitate: d.cantitate_roa, denumire: d.denumire })) : null,
onSave: () => {
if (orderNumber) openDashOrderDetail(orderNumber);
loadDashOrders();
}
});
}
// ── Dashboard row action handlers ────────────────
async function dashResyncOrder(orderNumber, btn) {
// Close dropdown immediately
const dd = btn.closest('.dropdown-menu');
if (dd) bootstrap.Dropdown.getInstance(dd.previousElementSibling)?.hide();
// Find the table row for visual feedback
const row = document.querySelector(`tr[data-order="${orderNumber}"]`) ||
btn.closest('tr');
try {
if (row) row.style.opacity = '0.5';
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadDashOrders();
} else {
if (row) row.style.opacity = '';
alert(data.message || 'Eroare la resync');
}
} catch (err) {
if (row) row.style.opacity = '';
alert('Eroare conexiune la resync');
}
}
async function dashDeleteOrder(orderNumber, btn) {
// Close dropdown immediately
const dd = btn.closest('.dropdown-menu');
if (dd) bootstrap.Dropdown.getInstance(dd.previousElementSibling)?.hide();
// Confirm before delete
if (!confirm(`Stergi comanda ${orderNumber} din ROA?`)) return;
// Find the table row for visual feedback
const row = document.querySelector(`tr[data-order="${orderNumber}"]`) ||
btn.closest('tr');
try {
if (row) row.style.opacity = '0.5';
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadDashOrders();
} else {
if (row) row.style.opacity = '';
alert(data.message || 'Eroare la stergere');
}
} catch (err) {
if (row) row.style.opacity = '';
alert('Eroare conexiune la stergere');
}
}