Files
gomag-vending/api/app/static/js/dashboard.js
Claude Agent a8292c2ef2 style(design): migrate to DESIGN.md system
Full visual migration: Space Grotesk + DM Sans + JetBrains Mono fonts,
warm amber two-accent system (amber=state, blue=action), dark mode with
CSS vars + localStorage + FOUC prevention, mobile bottom nav (5 tabs),
full-width tables, error/skipped dot glow, ~13 hardcoded hex replaced
with CSS vars in 4 JS files, 5 new E2E tests.

Files: style.css (full rewrite), base.html (fonts, theme script, dark
toggle, bottom nav), settings.html (dark toggle card), dashboard.js,
logs.js, mappings.js, settings.js (color vars), 5 templates (bnav
active blocks), test_design_system_e2e.py (NEW).

Cache-bust: style.css?v=18, shared.js?v=14, dashboard.js?v=27,
logs.js?v=13, mappings.js?v=12, settings.js?v=8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:36:07 +00:00

504 lines
20 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
// ── Init ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
await initPollInterval();
loadSchedulerStatus();
loadDashOrders();
startSyncPolling();
wireFilterBar();
});
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)';
}
}
}
// 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 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('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;
const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" 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-nowrap">${dateStr}</td>
${renderClientCell(o)}
<td><code>${esc(o.order_number)}</code></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="text-center">${invoiceDot(o)}</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 ? ' · <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: '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: '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: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' }
], (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="9" 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();
const isDiff = display !== shipping && shipping;
if (isDiff) {
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#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 'IMPORTED': return 'Importat';
case 'ALREADY_IMPORTED': return 'Deja imp.';
case 'SKIPPED': return 'Omis';
case 'ERROR': return 'Eroare';
default: return esc(status);
}
}
function invoiceDot(order) {
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '';
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';
return '<span class="dot dot-red" 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 ────────────────────────────
function openDashOrderDetail(orderNumber) {
_sharedModalQuickMapFn = openDashQuickMap;
renderOrderDetailModal(orderNumber, {
onQuickMap: openDashQuickMap,
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();
}
});
}