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>
This commit is contained in:
Claude Agent
2026-03-15 21:20:24 +00:00
parent 5a0ea462e5
commit 680f670037
10 changed files with 931 additions and 549 deletions

View File

@@ -1,13 +1,5 @@
/* ── Design tokens ───────────────────────────────── */ /* ── Design tokens ───────────────────────────────── */
:root { :root {
/* Sidebar */
--sidebar-width: 224px;
--sidebar-bg: #111827;
--sidebar-text: #d1d5db;
--sidebar-active-bg: #1f2937;
--sidebar-active-text: #ffffff;
--sidebar-border: #374151;
/* Surfaces */ /* Surfaces */
--body-bg: #f9fafb; --body-bg: #f9fafb;
--card-bg: #ffffff; --card-bg: #ffffff;
@@ -27,93 +19,89 @@
--text-secondary: #4b5563; --text-secondary: #4b5563;
--text-muted: #6b7280; --text-muted: #6b7280;
--border-color: #e5e7eb; --border-color: #e5e7eb;
/* Dots */
--dot-green: #22c55e;
--dot-yellow: #eab308;
--dot-red: #ef4444;
} }
/* ── Base ────────────────────────────────────────── */ /* ── Base ────────────────────────────────────────── */
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 0.875rem; font-size: 1rem;
background-color: var(--body-bg); background-color: var(--body-bg);
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
/* ── Sidebar ─────────────────────────────────────── */ /* ── Top Navbar ──────────────────────────────────── */
.sidebar { .top-navbar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: var(--sidebar-width); right: 0;
height: 100vh; height: 48px;
background-color: var(--sidebar-bg); background: #fff;
padding: 0; border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
z-index: 1000; z-index: 1000;
overflow-y: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
transition: transform 0.3s ease;
} }
.sidebar-header { .navbar-brand {
padding: 1.25rem 1rem; font-weight: 700;
border-bottom: 1px solid var(--sidebar-border); font-size: 1rem;
color: #111827;
white-space: nowrap;
} }
.sidebar-header h5 { .navbar-links {
color: #fff; display: flex;
margin: 0; align-items: stretch;
font-size: 1.1rem; gap: 0;
font-weight: 600; overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
} }
.navbar-links::-webkit-scrollbar { display: none; }
.sidebar .nav-link { .nav-tab {
color: var(--sidebar-text); display: flex;
font-size: 0.875rem; align-items: center;
padding: 0 1rem;
height: 48px;
color: #64748b;
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500; font-weight: 500;
padding: 0.5rem 0.75rem; border-bottom: 2px solid transparent;
border-radius: 0.375rem; white-space: nowrap;
margin: 0.125rem 0.5rem; flex-shrink: 0;
transition: background 0.15s, color 0.15s; transition: color 0.15s, border-color 0.15s;
} }
.nav-tab:hover {
.sidebar .nav-link:hover { color: #111827;
color: var(--sidebar-active-text); background: #f9fafb;
background-color: var(--sidebar-active-bg); text-decoration: none;
} }
.nav-tab.active {
.sidebar .nav-link.active { color: var(--blue-600);
color: var(--sidebar-active-text); border-bottom-color: var(--blue-600);
background-color: var(--sidebar-active-bg);
}
.sidebar .nav-link i {
margin-right: 0.5rem;
width: 1.2rem;
text-align: center;
}
.sidebar-footer {
position: absolute;
bottom: 0;
padding: 0.75rem 1rem;
border-top: 1px solid var(--sidebar-border);
width: 100%;
} }
/* ── Main content ────────────────────────────────── */ /* ── Main content ────────────────────────────────── */
.main-content { .main-content {
margin-left: var(--sidebar-width); padding-top: 64px;
padding: 1.5rem; padding-left: 1.5rem;
padding-right: 1.5rem;
padding-bottom: 1.5rem;
min-height: 100vh; min-height: 100vh;
} }
/* ── Sidebar toggle (mobile) ─────────────────────── */
.sidebar-toggle {
position: fixed;
top: 0.5rem;
left: 0.5rem;
z-index: 1100;
border-radius: 0.375rem;
}
/* ── Cards ───────────────────────────────────────── */ /* ── Cards ───────────────────────────────────────── */
.card { .card {
border: none; border: none;
@@ -126,17 +114,17 @@ body {
background: var(--card-bg); background: var(--card-bg);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.9375rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
/* ── Tables ──────────────────────────────────────── */ /* ── Tables ──────────────────────────────────────── */
.table { .table {
font-size: 0.875rem; font-size: 1rem;
} }
.table th { .table th {
font-size: 0.75rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -147,13 +135,14 @@ body {
} }
.table td { .table td {
padding: 0.75rem 1rem; padding: 0.625rem 1rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1rem;
} }
/* ── Badges — soft pill style ────────────────────── */ /* ── Badges — soft pill style ────────────────────── */
.badge { .badge {
font-size: 0.75rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
@@ -173,7 +162,7 @@ body {
/* ── Buttons ─────────────────────────────────────── */ /* ── Buttons ─────────────────────────────────────── */
.btn { .btn {
font-size: 0.875rem; font-size: 0.9375rem;
border-radius: 0.375rem; border-radius: 0.375rem;
} }
@@ -193,7 +182,7 @@ body {
/* ── Forms ───────────────────────────────────────── */ /* ── Forms ───────────────────────────────────────── */
.form-control, .form-select { .form-control, .form-select {
font-size: 0.875rem; font-size: 0.9375rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border-color: #d1d5db; border-color: #d1d5db;
@@ -204,12 +193,50 @@ body {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
} }
/* ── Pagination ──────────────────────────────────── */ /* ── Unified Pagination Bar ──────────────────────── */
.pagination .page-link { .pagination-bar {
font-size: 0.875rem; display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
} }
/* ── Loading spinner ─────────────────────────────── */ .page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
font-size: 0.8125rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
text-decoration: none;
user-select: none;
}
.page-btn:hover:not(:disabled):not(.active) {
background: #f3f4f6;
border-color: #9ca3af;
color: var(--text-primary);
text-decoration: none;
}
.page-btn.active {
background: var(--blue-600);
border-color: var(--blue-600);
color: #fff;
font-weight: 600;
}
.page-btn:disabled, .page-btn.disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
/* Loading spinner ────────────────────────────────── */
.spinner-overlay { .spinner-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -220,6 +247,42 @@ body {
justify-content: center; justify-content: center;
} }
/* ── Colored dots ────────────────────────────────── */
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-green { background: var(--dot-green); }
.dot-yellow { background: var(--dot-yellow); }
.dot-red { background: var(--dot-red); }
.dot-gray { background: #9ca3af; }
.dot-blue { background: #3b82f6; }
/* ── Flat row (mobile + desktop) ────────────────── */
.flat-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
font-size: 1rem;
}
.flat-row:last-child { border-bottom: none; }
.flat-row:hover { background: #f9fafb; cursor: pointer; }
.grow { flex: 1; min-width: 0; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Colored filter count - text color only ─────── */
.fc-green { color: #16a34a; }
.fc-yellow { color: #ca8a04; }
.fc-red { color: #dc2626; }
.fc-neutral { color: #6b7280; }
.fc-blue { color: #2563eb; }
/* ── Log viewer (dark theme — keep as-is) ────────── */ /* ── Log viewer (dark theme — keep as-is) ────────── */
.log-viewer { .log-viewer {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -270,7 +333,7 @@ body {
/* ── Order detail modal ──────────────────────────── */ /* ── Order detail modal ──────────────────────────── */
.modal-lg .table-sm td, .modal-lg .table-sm td,
.modal-lg .table-sm th { .modal-lg .table-sm th {
font-size: 0.8125rem; font-size: 0.875rem;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
@@ -320,7 +383,7 @@ tr.mapping-deleted td {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
font-size: 0.875rem; font-size: 0.9375rem;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s; transition: background 0.15s, border-color 0.15s;
white-space: nowrap; white-space: nowrap;
@@ -332,20 +395,12 @@ tr.mapping-deleted td {
color: #fff; color: #fff;
} }
.filter-pill.active .filter-count { .filter-pill.active .filter-count {
background: rgba(255,255,255,0.25); color: rgba(255,255,255,0.9);
color: #fff;
} }
.filter-count { .filter-count {
display: inline-block; font-size: 0.8125rem;
min-width: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-align: center;
line-height: 1.4;
} }
/* ── Search input ────────────────────────────────── */ /* ── Search input ────────────────────────────────── */
@@ -354,7 +409,7 @@ tr.mapping-deleted td {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.875rem; font-size: 0.9375rem;
outline: none; outline: none;
min-width: 180px; min-width: 180px;
} }
@@ -375,7 +430,7 @@ tr.mapping-deleted td {
.autocomplete-item { .autocomplete-item {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.9375rem;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
} }
.autocomplete-item:hover, .autocomplete-item.active { .autocomplete-item:hover, .autocomplete-item.active {
@@ -387,7 +442,7 @@ tr.mapping-deleted td {
} }
.autocomplete-item .denumire { .autocomplete-item .denumire {
color: #64748b; color: #64748b;
font-size: 0.8rem; font-size: 0.875rem;
} }
/* ── Tooltip for Client/Cont ─────────────────────── */ /* ── Tooltip for Client/Cont ─────────────────────── */
@@ -439,7 +494,7 @@ tr.mapping-deleted td {
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.875rem; font-size: 1rem;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background 0.12s;
@@ -451,7 +506,7 @@ tr.mapping-deleted td {
gap: 0.5rem; gap: 0.5rem;
padding: 0.4rem 1rem; padding: 0.4rem 1rem;
background: #eff6ff; background: #eff6ff;
font-size: 0.875rem; font-size: 1rem;
color: var(--blue-700); color: var(--blue-700);
border-top: 1px solid #dbeafe; border-top: 1px solid #dbeafe;
} }
@@ -489,14 +544,14 @@ tr.mapping-deleted td {
display: none; display: none;
gap: 0.375rem; gap: 0.375rem;
align-items: center; align-items: center;
font-size: 0.875rem; font-size: 0.9375rem;
} }
.period-custom-range.visible { display: flex; } .period-custom-range.visible { display: flex; }
/* ── select-compact (used in filter bars) ─────────── */ /* ── select-compact (used in filter bars) ─────────── */
.select-compact { .select-compact {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
font-size: 0.875rem; font-size: 0.9375rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
@@ -506,14 +561,14 @@ tr.mapping-deleted td {
/* ── btn-compact (kept for backward compat) ──────── */ /* ── btn-compact (kept for backward compat) ──────── */
.btn-compact { .btn-compact {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
font-size: 0.875rem; font-size: 0.9375rem;
} }
/* ── Result banner ───────────────────────────────── */ /* ── Result banner ───────────────────────────────── */
.result-banner { .result-banner {
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.875rem; font-size: 0.9375rem;
background: #d1fae5; background: #d1fae5;
color: #065f46; color: #065f46;
border: 1px solid #6ee7b7; border: 1px solid #6ee7b7;
@@ -521,7 +576,7 @@ tr.mapping-deleted td {
/* ── Badge-pct (mappings page) ───────────────────── */ /* ── Badge-pct (mappings page) ───────────────────── */
.badge-pct { .badge-pct {
font-size: 0.7rem; font-size: 0.75rem;
padding: 0.1rem 0.35rem; padding: 0.1rem 0.35rem;
border-radius: 4px; border-radius: 4px;
font-weight: 600; font-weight: 600;
@@ -529,10 +584,132 @@ tr.mapping-deleted td {
.badge-pct.complete { background: #d1fae5; color: #065f46; } .badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; } .badge-pct.incomplete { background: #fef3c7; color: #92400e; }
/* ── Context Menu ────────────────────────────────── */
.context-menu-trigger {
background: none;
border: none;
color: #9ca3af;
padding: 0.2rem 0.4rem;
cursor: pointer;
border-radius: 0.25rem;
font-size: 1rem;
line-height: 1;
transition: color 0.12s, background 0.12s;
}
.context-menu-trigger:hover {
color: var(--text-secondary);
background: #f3f4f6;
}
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
z-index: 1050;
min-width: 150px;
padding: 0.25rem 0;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.9rem;
font-size: 0.9375rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-primary);
transition: background 0.1s;
}
.context-menu-item:hover { background: #f3f4f6; }
.context-menu-item.text-danger { color: #dc2626; }
.context-menu-item.text-danger:hover { background: #fee2e2; }
/* ── Pagination info strip ───────────────────────── */
.pag-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.pag-strip-bottom {
border-bottom: none;
border-top: 1px solid var(--border-color);
}
/* ── Per page selector ───────────────────────────── */
.per-page-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9375rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ── Mobile list vs desktop table ────────────────── */
.mobile-list { display: none; }
/* ── Mappings flat-rows: always visible ────────────── */
.mappings-flat-list { display: block; }
/* ── Mobile ⋯ dropdown ─────────────────────────── */
.mobile-more-dropdown { position: relative; display: inline-block; }
.mobile-more-dropdown .dropdown-toggle::after { display: none; }
/* ── Mobile segmented control (hidden on desktop) ── */
.mobile-seg { display: none; }
/* ── Responsive ──────────────────────────────────── */ /* ── Responsive ──────────────────────────────────── */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.sidebar { transform: translateX(-100%); } .top-navbar {
.sidebar.show { transform: translateX(0); } padding: 0 0.5rem;
.main-content { margin-left: 0; } gap: 0.5rem;
.sidebar-toggle { display: block !important; } }
.navbar-brand {
font-size: 0.875rem;
}
.nav-tab {
padding: 0 0.625rem;
font-size: 0.8125rem;
}
.main-content {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.filter-bar {
gap: 0.375rem;
}
.filter-pill { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
.search-input { min-width: 0; width: 100%; order: 99; }
.page-btn.page-number { display: none; }
.page-btn.page-ellipsis { display: none; }
.table-responsive { display: none; }
.mobile-list { display: block; }
/* Segmented filter control (replaces pills on mobile) */
.filter-bar .filter-pill { display: none; }
.filter-bar .mobile-seg { display: flex; }
/* Sync card compact */
.sync-card-controls {
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
}
.sync-card-info {
flex-wrap: wrap;
gap: 0.375rem;
font-size: 0.8rem;
padding: 0.375rem 0.75rem;
}
/* Hide per-page selector on mobile */
.per-page-label { display: none; }
} }

View File

@@ -92,14 +92,13 @@ function updateSyncPanel(data) {
const st = document.getElementById('lastSyncStatus'); const st = document.getElementById('lastSyncStatus');
if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014'; 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 (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014';
// Updated counts: ↑new =already ⊘skipped ✕errors
if (cnt) { if (cnt) {
const newImp = lr.new_imported || 0; const newImp = lr.new_imported || 0;
const already = lr.already_imported || 0; const already = lr.already_imported || 0;
if (already > 0) { if (already > 0) {
cnt.textContent = '\u2191' + newImp + ' =' + already + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 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 { } else {
cnt.textContent = '\u2191' + (lr.imported || 0) + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0); 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) { if (st) {
@@ -300,13 +299,13 @@ async function loadDashOrders() {
if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') { if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') {
invoiceBadge = '<span class="text-muted">-</span>'; invoiceBadge = '<span class="text-muted">-</span>';
} else if (o.invoice && o.invoice.facturat) { } else if (o.invoice && o.invoice.facturat) {
invoiceBadge = `<span class="badge bg-success">Facturat</span>`; invoiceBadge = `<span style="color:#16a34a;font-weight:500">Facturat</span>`;
if (o.invoice.serie_act || o.invoice.numar_act) { if (o.invoice.serie_act || o.invoice.numar_act) {
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`; invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
} }
invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-'; invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
} else { } else {
invoiceBadge = '<span class="badge bg-danger">Nefacturat</span>'; invoiceBadge = `<span style="color:#dc2626">Nefacturat</span>`;
} }
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')"> return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
@@ -314,7 +313,7 @@ async function loadDashOrders() {
<td>${dateStr}</td> <td>${dateStr}</td>
${renderClientCell(o)} ${renderClientCell(o)}
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${statusBadge}</td> <td class="text-nowrap">${statusDot(o.status)} ${statusLabelText(o.status)}</td>
<td>${o.id_comanda || '-'}</td> <td>${o.id_comanda || '-'}</td>
<td>${invoiceBadge}</td> <td>${invoiceBadge}</td>
<td>${invoiceTotal}</td> <td>${invoiceTotal}</td>
@@ -322,20 +321,53 @@ async function loadDashOrders() {
}).join(''); }).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 ? Math.round(o.order_total) : '';
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 // Pagination
const pag = data.pagination || {}; const pag = data.pagination || {};
const totalPages = pag.total_pages || data.pages || 1; const totalPages = pag.total_pages || data.pages || 1;
const totalOrders = (data.counts || {}).total || data.total || 0; const totalOrders = (data.counts || {}).total || data.total || 0;
const pageInfo = `${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}`;
document.getElementById('dashPageInfo').textContent = pageInfo;
const pagInfoTop = document.getElementById('dashPageInfoTop');
if (pagInfoTop) pagInfoTop.textContent = pageInfo;
const pagHtml = totalPages > 1 ? ` const pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button> const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
<small class="text-muted">${dashPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
` : '';
const pagDiv = document.getElementById('dashPagination'); const pagDiv = document.getElementById('dashPagination');
if (pagDiv) pagDiv.innerHTML = pagHtml; if (pagDiv) pagDiv.innerHTML = pagHtml;
const pagDivTop = document.getElementById('dashPaginationTop'); const pagDivTop = document.getElementById('dashPaginationTop');
@@ -396,16 +428,15 @@ function escHtml(s) {
// Alias kept for backward compat with inline handlers in modal // Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); } function esc(s) { return escHtml(s); }
function fmtDate(dateStr) {
if (!dateStr) return '-'; function statusLabelText(status) {
try { switch ((status || '').toUpperCase()) {
const d = new Date(dateStr); case 'IMPORTED': return 'Importat';
const hasTime = dateStr.includes(':'); case 'ALREADY_IMPORTED': return 'Deja imp.';
if (hasTime) { case 'SKIPPED': return 'Omis';
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); case 'ERROR': return 'Eroare';
} default: return esc(status);
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); }
} catch { return dateStr; }
} }
function orderStatusBadge(status) { function orderStatusBadge(status) {

View File

@@ -10,14 +10,6 @@ let currentQmOrderNumber = '';
let ordersSortColumn = 'order_date'; let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc'; let ordersSortDirection = 'desc';
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fmtDuration(startedAt, finishedAt) { function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-'; if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt); const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -27,24 +19,12 @@ function fmtDuration(startedAt, finishedAt) {
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
} }
function fmtDate(dateStr) {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) {
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
function runStatusBadge(status) { function runStatusBadge(status) {
switch ((status || '').toLowerCase()) { switch ((status || '').toLowerCase()) {
case 'completed': return '<span class="badge bg-success">completed</span>'; case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
case 'running': return '<span class="badge bg-primary">running</span>'; case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
case 'failed': return '<span class="badge bg-danger">failed</span>'; case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`; default: return `<span style="font-weight:600">${esc(status)}</span>`;
} }
} }
@@ -58,6 +38,18 @@ function orderStatusBadge(status) {
} }
} }
function logStatusText(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 logsGoPage(p) { loadRunOrders(currentRunId, null, p); }
// ── Runs Dropdown ──────────────────────────────── // ── Runs Dropdown ────────────────────────────────
async function loadRuns() { async function loadRuns() {
@@ -88,6 +80,8 @@ async function loadRuns() {
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`; return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
}).join(''); }).join('');
} }
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile) ddMobile.innerHTML = dd.innerHTML;
} catch (err) { } catch (err) {
const dd = document.getElementById('runsDropdown'); const dd = document.getElementById('runsDropdown');
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`; dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
@@ -110,6 +104,8 @@ async function selectRun(runId) {
// Sync dropdown selection // Sync dropdown selection
const dd = document.getElementById('runsDropdown'); const dd = document.getElementById('runsDropdown');
if (dd && dd.value !== runId) dd.value = runId; if (dd && dd.value !== runId) dd.value = runId;
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
if (!runId) { if (!runId) {
document.getElementById('logViewerSection').style.display = 'none'; document.getElementById('logViewerSection').style.display = 'none';
@@ -117,8 +113,8 @@ async function selectRun(runId) {
} }
document.getElementById('logViewerSection').style.display = ''; document.getElementById('logViewerSection').style.display = '';
document.getElementById('logRunId').textContent = runId; const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
document.getElementById('logStatusBadge').innerHTML = '<span class="badge bg-secondary">...</span>'; document.getElementById('logStatusBadge').innerHTML = '...';
document.getElementById('textLogSection').style.display = 'none'; document.getElementById('textLogSection').style.display = 'none';
await loadRunOrders(runId, 'all', 1); await loadRunOrders(runId, 'all', 1);
@@ -133,13 +129,9 @@ async function loadRunOrders(runId, statusFilter, page) {
if (statusFilter != null) currentFilter = statusFilter; if (statusFilter != null) currentFilter = statusFilter;
if (page != null) ordersPage = page; if (page != null) ordersPage = page;
// Update filter button styles // Update filter pill active state
document.querySelectorAll('#orderFilterBtns button').forEach(btn => { document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary') btn.classList.toggle('active', btn.dataset.logStatus === currentFilter);
.replace(' btn-success', ' btn-outline-success')
.replace(' btn-info', ' btn-outline-info')
.replace(' btn-warning', ' btn-outline-warning')
.replace(' btn-danger', ' btn-outline-danger');
}); });
try { try {
@@ -155,15 +147,6 @@ async function loadRunOrders(runId, statusFilter, page) {
const alreadyEl = document.getElementById('countAlreadyImported'); const alreadyEl = document.getElementById('countAlreadyImported');
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0; if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
// Highlight active filter
const filterMap = { 'all': 0, 'IMPORTED': 1, 'ALREADY_IMPORTED': 2, 'SKIPPED': 3, 'ERROR': 4 };
const btns = document.querySelectorAll('#orderFilterBtns button');
const idx = filterMap[currentFilter] ?? 0;
if (btns[idx]) {
const colorMap = ['primary', 'success', 'info', 'warning', 'danger'];
btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
}
const tbody = document.getElementById('runOrdersBody'); const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];
@@ -178,32 +161,62 @@ async function loadRunOrders(runId, statusFilter, page) {
<td><code>${esc(o.order_number)}</code></td> <td><code>${esc(o.order_number)}</code></td>
<td>${esc(o.customer_name)}</td> <td>${esc(o.customer_name)}</td>
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${orderStatusBadge(o.status)}</td> <td class="text-nowrap">${statusDot(o.status)} ${logStatusText(o.status)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
// Mobile flat rows
const mobileList = document.getElementById('logsMobileList');
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 totalStr = o.order_total ? Math.round(o.order_total) : '';
return `<div class="flat-row" onclick="openOrderDetail('${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(o.customer_name || '—')}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + totalStr : ''}</span>
</div>`;
}).join('');
}
}
// Mobile segmented control
renderMobileSegmented('logsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: currentFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: counts.imported || 0, value: 'IMPORTED', active: currentFilter === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Deja', count: counts.already_imported || 0, value: 'ALREADY_IMPORTED', active: currentFilter === 'ALREADY_IMPORTED', colorClass: 'fc-blue' },
{ label: 'Omise', count: counts.skipped || 0, value: 'SKIPPED', active: currentFilter === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: counts.error || 0, value: 'ERROR', active: currentFilter === 'ERROR', colorClass: 'fc-red' }
], (val) => filterOrders(val));
// Orders pagination // Orders pagination
const totalPages = data.pages || 1; const totalPages = data.pages || 1;
const infoEl = document.getElementById('ordersPageInfo'); const infoEl = document.getElementById('ordersPageInfo');
infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`; if (infoEl) infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
const pagHtml = `<small class="text-muted me-auto">${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}</small>` + renderUnifiedPagination(ordersPage, totalPages, 'logsGoPage');
const pagDiv = document.getElementById('ordersPagination'); const pagDiv = document.getElementById('ordersPagination');
if (totalPages > 1) { if (pagDiv) pagDiv.innerHTML = pagHtml;
pagDiv.innerHTML = ` const pagDivTop = document.getElementById('ordersPaginationTop');
<button class="btn btn-sm btn-outline-secondary" ${ordersPage <= 1 ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage - 1})"><i class="bi bi-chevron-left"></i></button> if (pagDivTop) pagDivTop.innerHTML = pagHtml;
<small class="text-muted">${ordersPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${ordersPage >= totalPages ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
// Update run status badge // Update run status badge
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`); const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
const runData = await runRes.json(); const runData = await runRes.json();
if (runData.run) { if (runData.run) {
document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status); document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status);
// Update mobile run dot
const mDot = document.getElementById('mobileRunDot');
if (mDot) mDot.className = 'sync-status-dot ' + (runData.run.status || 'idle');
} }
} catch (err) { } catch (err) {
document.getElementById('runOrdersBody').innerHTML = document.getElementById('runOrdersBody').innerHTML =
@@ -517,6 +530,12 @@ async function saveQuickMapping() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadRuns(); loadRuns();
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.addEventListener('click', function() {
filterOrders(this.dataset.logStatus || 'all');
});
});
const preselected = document.getElementById('preselectedRun'); const preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : ''); const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
@@ -533,4 +552,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
} }
}); });
document.getElementById('autoRefreshToggleMobile')?.addEventListener('change', (e) => {
const desktop = document.getElementById('autoRefreshToggle');
if (desktop) desktop.checked = e.target.checked;
desktop?.dispatchEvent(new Event('change'));
});
}); });

View File

@@ -1,4 +1,5 @@
let currentPage = 1; let currentPage = 1;
let mappingsPerPage = 50;
let currentSearch = ''; let currentSearch = '';
let searchTimeout = null; let searchTimeout = null;
let sortColumn = 'sku'; let sortColumn = 'sku';
@@ -69,6 +70,20 @@ function updatePctCounts(counts) {
if (elAll) elAll.textContent = counts.total || 0; if (elAll) elAll.textContent = counts.total || 0;
if (elComplete) elComplete.textContent = counts.complete || 0; if (elComplete) elComplete.textContent = counts.complete || 0;
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0; if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
// Mobile segmented control
renderMobileSegmented('mappingsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' },
{ label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' }
], (val) => {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`);
if (pill) pill.classList.add('active');
pctFilter = val;
currentPage = 1;
loadMappings();
});
} }
// ── Load & Render ──────────────────────────────── // ── Load & Render ────────────────────────────────
@@ -79,7 +94,7 @@ async function loadMappings() {
const params = new URLSearchParams({ const params = new URLSearchParams({
search: currentSearch, search: currentSearch,
page: currentPage, page: currentPage,
per_page: 50, per_page: mappingsPerPage,
sort_by: sortColumn, sort_by: sortColumn,
sort_dir: sortDirection sort_dir: sortDirection
}); });
@@ -103,116 +118,129 @@ async function loadMappings() {
renderPagination(data); renderPagination(data);
updateSortIcons(); updateSortIcons();
} catch (err) { } catch (err) {
document.getElementById('mappingsBody').innerHTML = document.getElementById('mappingsFlatList').innerHTML =
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`; `<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
} }
} }
function renderTable(mappings, showDeleted) { function renderTable(mappings, showDeleted) {
const tbody = document.getElementById('mappingsBody'); const container = document.getElementById('mappingsFlatList');
if (!mappings || mappings.length === 0) { if (!mappings || mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>'; container.innerHTML = '<div class="flat-row text-muted py-4 justify-content-center">Nu exista mapari</div>';
return; return;
} }
// Group by SKU for visual grouping (R6)
let html = '';
let prevSku = null; let prevSku = null;
let groupIdx = 0; let html = '';
let skuGroupCounts = {};
// Count items per SKU
mappings.forEach(m => { mappings.forEach(m => {
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
});
mappings.forEach((m, i) => {
const isNewGroup = m.sku !== prevSku; const isNewGroup = m.sku !== prevSku;
if (isNewGroup) groupIdx++;
const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
const isMulti = skuGroupCounts[m.sku] > 1;
const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
const deletedClass = m.sters ? 'mapping-deleted' : '';
// SKU cell: show only on first row of group
let skuCell, productCell;
if (isNewGroup) { if (isNewGroup) {
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
// Percentage total badge
let pctBadge = ''; let pctBadge = '';
if (m.pct_total !== undefined) { if (m.pct_total !== undefined) {
if (m.is_complete) { pctBadge = m.is_complete
pctBadge = ` <span class="badge-pct complete" title="100% alocat">&#10003; 100%</span>`; ? ` <span class="badge-pct complete">&#10003; 100%</span>`
} else { : ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(2) : m.pct_total;
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">&#9888; ${pctVal}%</span>`;
}
} }
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`; const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`; html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
} else { <span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
skuCell = ''; ${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
productCell = ''; title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${pctBadge}
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
${m.sters
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">&#8942;</button>`
}
</div>`;
} }
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}"> html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
${skuCell} <code>${esc(m.codmat)}</code>
${productCell} <span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
<td><code>${esc(m.codmat)}</code></td> <span class="text-nowrap" style="font-size:0.875rem">
<td>${esc(m.denumire || '-')}</td> <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
<td>${esc(m.um || '-')}</td> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td> · <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
<td> </span>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'} </div>`;
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
${m.activ ? 'Activ' : 'Inactiv'}
</span>
</td>
<td>
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
<i class="bi bi-trash"></i>
</button>`}
</td>
</tr>`;
prevSku = m.sku; prevSku = m.sku;
}); });
container.innerHTML = html;
tbody.innerHTML = html; // Wire context menu triggers
container.querySelectorAll('.context-menu-trigger').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const { sku, codmat, cantitate, procent } = btn.dataset;
const rect = btn.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, [
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) },
{ label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true }
]);
});
});
}
// Inline edit for flat-row values (cantitate / procent)
function editFlatValue(span, sku, codmat, field, currentValue) {
if (span.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm d-inline';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '70px';
input.style.display = 'inline';
const originalText = span.textContent;
span.textContent = '';
span.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
span.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) { loadMappings(); }
else { span.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { span.textContent = originalText; }
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { span.textContent = originalText; }
});
} }
function renderPagination(data) { function renderPagination(data) {
const info = document.getElementById('pageInfo'); const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`; const infoHtml = `<small class="text-muted me-auto">${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}</small>`;
const pagHtml = infoHtml + renderUnifiedPagination(data.page, data.pages || 1, 'goPage', pagOpts);
const ul = document.getElementById('pagination'); const top = document.getElementById('mappingsPagTop');
if (data.pages <= 1) { ul.innerHTML = ''; return; } const bot = document.getElementById('mappingsPagBottom');
if (top) top.innerHTML = pagHtml;
let html = ''; if (bot) bot.innerHTML = pagHtml;
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`;
let start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 6);
for (let i = start; i <= end; i++) {
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
}
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">&raquo;</a></li>`;
ul.innerHTML = html;
} }
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
function goPage(p) { function goPage(p) {
currentPage = p; currentPage = p;
loadMappings(); loadMappings();
@@ -411,36 +439,34 @@ async function saveMapping() {
let inlineAddVisible = false; let inlineAddVisible = false;
function showInlineAddRow() { function showInlineAddRow() {
// On mobile, open the full modal instead
if (window.innerWidth < 768) {
new bootstrap.Modal(document.getElementById('addModal')).show();
return;
}
if (inlineAddVisible) return; if (inlineAddVisible) return;
inlineAddVisible = true; inlineAddVisible = true;
const tbody = document.getElementById('mappingsBody'); const container = document.getElementById('mappingsFlatList');
const row = document.createElement('tr'); const row = document.createElement('div');
row.id = 'inlineAddRow'; row.id = 'inlineAddRow';
row.className = 'table-info'; row.className = 'flat-row';
row.style.background = '#eff6ff';
row.style.gap = '0.5rem';
row.innerHTML = ` row.innerHTML = `
<td colspan="2"> <input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px"> <div class="position-relative" style="flex:1;min-width:0">
</td>
<td colspan="2" class="position-relative">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off"> <input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div> <div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
<small class="text-muted" id="inlineSelected"></small> <small class="text-muted" id="inlineSelected"></small>
</td> </div>
<td>-</td> <input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
<td> <input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px"> <button class="btn btn-sm btn-success" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
</td> <button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
<td>
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
</td>
<td>-</td>
<td>
<button class="btn btn-sm btn-success me-1" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
</td>
`; `;
tbody.insertBefore(row, tbody.firstChild); container.insertBefore(row, container.firstChild);
document.getElementById('inlineSku').focus(); document.getElementById('inlineSku').focus();
// Setup autocomplete for inline CODMAT // Setup autocomplete for inline CODMAT
@@ -515,51 +541,6 @@ function cancelInlineAdd() {
inlineAddVisible = false; inlineAddVisible = false;
} }
// ── Inline Edit ──────────────────────────────────
function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '80px';
const originalText = td.textContent;
td.textContent = '';
td.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
td.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) { loadMappings(); }
else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { td.textContent = originalText; }
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { td.textContent = originalText; }
});
}
// ── Toggle Active with Toast Undo ──────────────── // ── Toggle Active with Toast Undo ────────────────
async function toggleActive(sku, codmat, currentActive) { async function toggleActive(sku, codmat, currentActive) {
@@ -714,7 +695,3 @@ function handleMappingConflict(data) {
} }
} }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

214
api/app/static/js/shared.js Normal file
View File

@@ -0,0 +1,214 @@
// shared.js - Unified utilities for all pages
// ── HTML escaping ─────────────────────────────────
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── Date formatting ───────────────────────────────
function fmtDate(dateStr, includeSeconds) {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) {
const opts = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' };
if (includeSeconds) opts.second = '2-digit';
return d.toLocaleString('ro-RO', opts);
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
// ── Unified Pagination ────────────────────────────
/**
* Renders a full pagination bar with First/Prev/numbers/Next/Last.
* @param {number} currentPage
* @param {number} totalPages
* @param {string} goToFnName - name of global function to call with page number
* @param {object} [opts] - optional: { perPage, perPageFn, perPageOptions }
* @returns {string} HTML string
*/
function renderUnifiedPagination(currentPage, totalPages, goToFnName, opts) {
if (totalPages <= 1 && !(opts && opts.perPage)) {
return '';
}
let html = '<div class="d-flex align-items-center gap-2 flex-wrap">';
// Per-page selector
if (opts && opts.perPage && opts.perPageFn) {
const options = opts.perPageOptions || [25, 50, 100, 250];
html += `<label class="per-page-label">Per pagina: <select class="select-compact ms-1" onchange="${opts.perPageFn}(this.value)">`;
options.forEach(v => {
html += `<option value="${v}"${v === opts.perPage ? ' selected' : ''}>${v}</option>`;
});
html += '</select></label>';
}
if (totalPages <= 1) {
html += '</div>';
return html;
}
html += '<div class="pagination-bar">';
// First
html += `<button class="page-btn" onclick="${goToFnName}(1)" ${currentPage <= 1 ? 'disabled' : ''}>&laquo;</button>`;
// Prev
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>&lsaquo;</button>`;
// Page numbers with ellipsis
const range = 2;
let pages = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - range && i <= currentPage + range)) {
pages.push(i);
}
}
let lastP = 0;
pages.forEach(p => {
if (lastP && p - lastP > 1) {
html += `<span class="page-btn disabled page-ellipsis">…</span>`;
}
html += `<button class="page-btn page-number${p === currentPage ? ' active' : ''}" onclick="${goToFnName}(${p})">${p}</button>`;
lastP = p;
});
// Next
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>&rsaquo;</button>`;
// Last
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>&raquo;</button>`;
html += '</div></div>';
return html;
}
// ── Context Menu ──────────────────────────────────
let _activeContextMenu = null;
function closeAllContextMenus() {
if (_activeContextMenu) {
_activeContextMenu.remove();
_activeContextMenu = null;
}
}
document.addEventListener('click', closeAllContextMenus);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllContextMenus();
});
/**
* Show a context menu at the given position.
* @param {number} x - clientX
* @param {number} y - clientY
* @param {Array} items - [{label, action, danger}]
*/
function showContextMenu(x, y, items) {
closeAllContextMenus();
const menu = document.createElement('div');
menu.className = 'context-menu';
items.forEach(item => {
const btn = document.createElement('button');
btn.className = 'context-menu-item' + (item.danger ? ' text-danger' : '');
btn.textContent = item.label;
btn.addEventListener('click', (e) => {
e.stopPropagation();
closeAllContextMenus();
item.action();
});
menu.appendChild(btn);
});
document.body.appendChild(menu);
_activeContextMenu = menu;
// Position menu, keeping it within viewport
const rect = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = x;
let top = y;
if (left + 160 > vw) left = vw - 165;
if (top + rect.height > vh) top = vh - rect.height - 5;
menu.style.left = left + 'px';
menu.style.top = top + 'px';
}
/**
* Wire right-click on desktop + three-dots button on mobile for a table.
* @param {string} rowSelector - CSS selector for clickable rows
* @param {function} menuItemsFn - called with row element, returns [{label, action, danger}]
*/
function initContextMenus(rowSelector, menuItemsFn) {
document.addEventListener('contextmenu', (e) => {
const row = e.target.closest(rowSelector);
if (!row) return;
e.preventDefault();
showContextMenu(e.clientX, e.clientY, menuItemsFn(row));
});
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.context-menu-trigger');
if (!trigger) return;
const row = trigger.closest(rowSelector);
if (!row) return;
e.stopPropagation();
const rect = trigger.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, menuItemsFn(row));
});
}
// ── Mobile segmented control ─────────────────────
/**
* Render a Bootstrap btn-group segmented control for mobile.
* @param {string} containerId - ID of the container div
* @param {Array} pills - [{label, count, colorClass, value, active}]
* @param {function} onSelect - callback(value)
*/
function renderMobileSegmented(containerId, pills, onSelect) {
const container = document.getElementById(containerId);
if (!container) return;
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
}).join('')}</div>`;
container.querySelectorAll('[data-seg-value]').forEach(btn => {
btn.addEventListener('click', () => onSelect(btn.dataset.segValue));
});
}
// ── Dot helper ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED':
case 'ALREADY_IMPORTED':
case 'COMPLETED':
case 'RESOLVED':
return '<span class="dot dot-green"></span>';
case 'SKIPPED':
case 'UNRESOLVED':
case 'INCOMPLETE':
return '<span class="dot dot-yellow"></span>';
case 'ERROR':
case 'FAILED':
return '<span class="dot dot-red"></span>';
default:
return '<span class="dot dot-gray"></span>';
}
}

View File

@@ -6,52 +6,27 @@
<title>{% block title %}GoMag Import Manager{% endblock %}</title> <title>{% block title %}GoMag Import Manager{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet"> <link href="/static/css/style.css?v=5" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Sidebar --> <!-- Top Navbar -->
<nav id="sidebar" class="sidebar"> <nav class="top-navbar">
<div class="sidebar-header"> <div class="navbar-brand">GoMag Import</div>
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5> <div class="navbar-links">
</div> <a href="/" class="nav-tab {% block nav_dashboard %}{% endblock %}"><span class="d-none d-md-inline">Dashboard</span><span class="d-md-none">Acasa</span></a>
<ul class="nav flex-column"> <a href="/mappings" class="nav-tab {% block nav_mappings %}{% endblock %}"><span class="d-none d-md-inline">Mapari SKU</span><span class="d-md-none">Mapari</span></a>
<li class="nav-item"> <a href="/missing-skus" class="nav-tab {% block nav_missing %}{% endblock %}"><span class="d-none d-md-inline">SKU-uri Lipsa</span><span class="d-md-none">Lipsa</span></a>
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/"> <a href="/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
<i class="bi bi-link-45deg"></i> Mapari SKU
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_missing %}{% endblock %}" href="/missing-skus">
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_logs %}{% endblock %}" href="/logs">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
</li>
</ul>
<div class="sidebar-footer">
<small class="text-muted">v1.0</small>
</div> </div>
</nav> </nav>
<!-- Mobile toggle -->
<button class="btn btn-dark d-md-none sidebar-toggle" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
<i class="bi bi-list"></i>
</button>
<!-- Main content --> <!-- Main content -->
<main class="main-content"> <main class="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/shared.js?v=5"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -11,7 +11,7 @@
<div class="sync-card-controls"> <div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span> <span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" class="text-secondary">Inactiv</span> <span id="syncStatusText" class="text-secondary">Inactiv</span>
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2">
<label class="d-flex align-items-center gap-1 text-muted"> <label class="d-flex align-items-center gap-1 text-muted">
Auto: Auto:
<input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()"> <input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
@@ -63,31 +63,19 @@
<input type="date" id="periodEnd" class="select-compact"> <input type="date" id="periodEnd" class="select-compact">
</div> </div>
<!-- Status pills --> <!-- Status pills -->
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" id="cntAll">0</span></button> <button class="filter-pill active d-none d-md-inline-flex" data-status="all">Toate <span class="filter-count fc-neutral" id="cntAll">0</span></button>
<button class="filter-pill" data-status="IMPORTED">Importat <span class="filter-count" id="cntImp">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="IMPORTED">Importat <span class="filter-count fc-green" id="cntImp">0</span></button>
<button class="filter-pill" data-status="SKIPPED">Omise <span class="filter-count" id="cntSkip">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="cntSkip">0</span></button>
<button class="filter-pill" data-status="ERROR">Erori <span class="filter-count" id="cntErr">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="ERROR">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
<button class="filter-pill" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefact. <span class="filter-count fc-neutral" id="cntNef">0</span></button>
<!-- Search (integrated, end of row) --> <!-- Search (integrated, end of row) -->
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input"> <input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
</div> </div>
<div class="d-md-none mb-2" id="dashMobileSeg"></div>
</div> </div>
<!-- Pagination top bar --> <div id="dashPaginationTop" class="pag-strip"></div>
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center gap-2">
<small class="text-muted" id="dashPageInfoTop"></small>
<div class="d-flex align-items-center gap-2">
<label class="text-muted text-nowrap">Per pagina:
<select id="perPageSelect" class="select-compact ms-1" onchange="dashChangePerPage(this.value)">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</label>
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
</div>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="dashMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
@@ -108,10 +96,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="dashPageInfo"></small>
<div id="dashPagination" class="d-flex align-items-center gap-2"></div>
</div>
</div> </div>
<!-- Order Detail Modal --> <!-- Order Detail Modal -->
@@ -193,5 +178,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js?v=5"></script>
{% endblock %} {% endblock %}

View File

@@ -5,59 +5,64 @@
{% block content %} {% block content %}
<h4 class="mb-4">Jurnale Import</h4> <h4 class="mb-4">Jurnale Import</h4>
<!-- Sync Run Selector --> <!-- Sync Run Selector + Status + Controls (single card) -->
<div class="card mb-4"> <div class="card mb-3">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex align-items-center gap-3"> <!-- Desktop layout -->
<div class="d-none d-md-flex align-items-center gap-3 flex-wrap">
<label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label> <label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label>
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)"> <select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)" style="max-width:400px">
<option value="">Se incarca...</option> <option value="">Se incarca...</option>
</select> </select>
<button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button> <button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
<span id="logStatusBadge" style="font-weight:600">-</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked>
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
<i class="bi bi-file-text"></i> Log text brut
</button>
</div>
<!-- Mobile compact layout -->
<div class="d-flex d-md-none align-items-center gap-2">
<span id="mobileRunDot" class="sync-status-dot idle" style="width:8px;height:8px"></span>
<select class="form-select form-select-sm flex-grow-1" id="runsDropdownMobile" onchange="selectRun(this.value)" style="font-size:0.8rem">
<option value="">Se incarca...</option>
</select>
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca"><i class="bi bi-arrow-clockwise"></i></button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<label class="dropdown-item d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" id="autoRefreshToggleMobile" checked> Auto-refresh
</label>
</li>
<li><a class="dropdown-item" href="#" onclick="toggleTextLog();return false"><i class="bi bi-file-text me-1"></i> Log text brut</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Detail Viewer (shown when run selected) --> <!-- Detail Viewer (shown when run selected) -->
<div id="logViewerSection" style="display:none;"> <div id="logViewerSection" style="display:none;">
<!-- Filter bar --> <!-- Filter pills -->
<div class="card mb-3"> <div class="filter-bar mb-3" id="orderFilterPills">
<div class="card-header d-flex justify-content-between align-items-center"> <button class="filter-pill active d-none d-md-inline-flex" data-log-status="all">Toate <span class="filter-count fc-neutral" id="countAll">0</span></button>
<span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span> <button class="filter-pill d-none d-md-inline-flex" data-log-status="IMPORTED">Importate <span class="filter-count fc-green" id="countImported">0</span></button>
<div class="d-flex align-items-center gap-3"> <button class="filter-pill d-none d-md-inline-flex" data-log-status="ALREADY_IMPORTED">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
<div class="form-check form-switch mb-0"> <button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked> <button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
<i class="bi bi-file-text"></i> Log text brut
</button>
</div>
</div>
<div class="card-body py-2">
<div class="btn-group" role="group" id="orderFilterBtns">
<button type="button" class="btn btn-sm btn-primary" onclick="filterOrders('all')">
Toate <span class="badge bg-light text-dark ms-1" id="countAll">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="filterOrders('IMPORTED')">
Importate <span class="badge bg-light text-dark ms-1" id="countImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="filterOrders('ALREADY_IMPORTED')">
Deja imp. <span class="badge bg-light text-dark ms-1" id="countAlreadyImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="filterOrders('SKIPPED')">
Omise <span class="badge bg-light text-dark ms-1" id="countSkipped">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="filterOrders('ERROR')">
Erori <span class="badge bg-light text-dark ms-1" id="countError">0</span>
</button>
</div>
</div>
</div> </div>
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
<!-- Orders table --> <!-- Orders table -->
<div class="card mb-3"> <div class="card mb-3">
<div id="ordersPaginationTop" class="pag-strip"></div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="logsMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
@@ -76,10 +81,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="ordersPagination" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="ordersPageInfo"></small>
<div id="ordersPagination" class="d-flex align-items-center gap-2"></div>
</div>
</div> </div>
<!-- Collapsible text log --> <!-- Collapsible text log -->
@@ -173,5 +175,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/logs.js"></script> <script src="/static/js/logs.js?v=5"></script>
{% endblock %} {% endblock %}

View File

@@ -5,12 +5,23 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4> <h4 class="mb-0">Mapari SKU</h4>
<div> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button> <!-- Desktop buttons -->
<button class="btn btn-sm btn-outline-secondary" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> Adauga Mapare</button> <button class="btn btn-sm btn-outline-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button> <button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</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="downloadTemplate();return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Template CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-1"></i> Import CSV</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right me-1"></i> Formular complet</a></li>
</ul>
</div>
</div> </div>
</div> </div>
@@ -38,41 +49,23 @@
<!-- Percentage filter pills --> <!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar"> <div class="filter-bar" id="mappingsFilterBar">
<button class="filter-pill active" data-pct="all">Toate <span class="filter-count" id="mCntAll">0</span></button> <button class="filter-pill active d-none d-md-inline-flex" data-pct="all">Toate <span class="filter-count fc-neutral" id="mCntAll">0</span></button>
<button class="filter-pill" data-pct="complete">Complete &#10003; <span class="filter-count" id="mCntComplete">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-pct="complete">Complete <span class="filter-count fc-green" id="mCntComplete">0</span></button>
<button class="filter-pill" data-pct="incomplete">Incomplete &#9888; <span class="filter-count" id="mCntIncomplete">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
</div> </div>
<div class="d-md-none mb-2" id="mappingsMobileSeg"></div>
<!-- Table --> <!-- Top pagination -->
<div id="mappingsPagTop" class="pag-strip"></div>
<!-- Flat-row list (unified desktop + mobile) -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div id="mappingsFlatList" class="mappings-flat-list">
<table class="table table-hover mb-0"> <div class="flat-row text-muted py-4 justify-content-center">Se incarca...</div>
<thead>
<tr>
<th class="sortable" onclick="sortBy('sku')">SKU <span class="sort-icon" data-col="sku"></span></th>
<th>Produs Web</th>
<th class="sortable" onclick="sortBy('codmat')">CODMAT <span class="sort-icon" data-col="codmat"></span></th>
<th class="sortable" onclick="sortBy('denumire')">Denumire <span class="sort-icon" data-col="denumire"></span></th>
<th>UM</th>
<th class="sortable" onclick="sortBy('cantitate_roa')">Cantitate ROA <span class="sort-icon" data-col="cantitate_roa"></span></th>
<th class="sortable" onclick="sortBy('procent_pret')">Procent Pret <span class="sort-icon" data-col="procent_pret"></span></th>
<th class="sortable" onclick="sortBy('activ')">Activ <span class="sort-icon" data-col="activ"></span></th>
<th style="width:100px">Actiuni</th>
</tr>
</thead>
<tbody id="mappingsBody">
<tr><td colspan="9" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="mappingsPagBottom" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="pageInfo"></small>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
</nav>
</div>
</div> </div>
<!-- Add/Edit Modal with multi-CODMAT support (R11) --> <!-- Add/Edit Modal with multi-CODMAT support (R11) -->
@@ -161,5 +154,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/mappings.js"></script> <script src="/static/js/mappings.js?v=5"></script>
{% endblock %} {% endblock %}

View File

@@ -5,63 +5,65 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">SKU-uri Lipsa</h4> <h4 class="mb-0">SKU-uri Lipsa</h4>
<div> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()"> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV <i class="bi bi-download"></i> Export CSV
</button> </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>
</div> </div>
<!-- Unified filter bar --> <!-- Unified filter bar -->
<div class="filter-bar" id="skusFilterBar"> <div class="filter-bar" id="skusFilterBar">
<button class="filter-pill active" data-sku-status="unresolved"> <button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
Nerezolvate <span class="filter-count" id="cntUnres">0</span> Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
</button> </button>
<button class="filter-pill" data-sku-status="resolved"> <button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
Rezolvate <span class="filter-count" id="cntRes">0</span> Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
</button> </button>
<button class="filter-pill" data-sku-status="all"> <button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
Toate <span class="filter-count" id="cntAllSkus">0</span> Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
</button> </button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input"> <input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2">&#8635; Re-scan</button> <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 id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
<span class="sync-live-dot"></span> <span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span> <span id="rescanProgressText">Scanare...</span>
</span> </span>
</div> </div>
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
<!-- Result banner --> <!-- Result banner -->
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div> <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">
<div class="card-body p-0"> <div class="card-body p-0">
<div id="missingMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th>Status</th>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>Nr. Comenzi</th>
<th>Client</th>
<th>First Seen</th>
<th>Status</th>
<th>Actiune</th> <th>Actiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="missingBody"> <tbody id="missingBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr> <tr><td colspan="4" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer">
<small class="text-muted" id="missingInfo"></small>
</div>
</div> </div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
<nav id="paginationNav" class="mt-3">
<ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav>
<!-- Map SKU Modal with multi-CODMAT support (R11) --> <!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1"> <div class="modal fade" id="mapModal" tabindex="-1">
@@ -98,7 +100,9 @@ let currentMapSku = '';
let mapAcTimeout = null; let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let skuStatusFilter = 'unresolved'; let skuStatusFilter = 'unresolved';
const perPage = 20; let missingPerPage = 20;
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
// ── Filter pills ────────────────────────────────── // ── Filter pills ──────────────────────────────────
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => { document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
@@ -158,7 +162,7 @@ function loadMissingSkus(page) {
const resolvedVal = resolvedParamFor(skuStatusFilter); const resolvedVal = resolvedParamFor(skuStatusFilter);
params.set('resolved', resolvedVal); params.set('resolved', resolvedVal);
params.set('page', currentPage); params.set('page', currentPage);
params.set('per_page', perPage); params.set('per_page', missingPerPage);
const search = document.getElementById('skuSearch')?.value?.trim(); const search = document.getElementById('skuSearch')?.value?.trim();
if (search) params.set('search', search); if (search) params.set('search', search);
@@ -170,12 +174,27 @@ function loadMissingSkus(page) {
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0; if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0; if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 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); renderMissingSkusTable(data.skus || data.missing_skus || [], data);
renderPagination(data); renderPagination(data);
}) })
.catch(err => { .catch(err => {
document.getElementById('missingBody').innerHTML = document.getElementById('missingBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`; `<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
}); });
} }
@@ -184,38 +203,24 @@ function loadMissing(page) { loadMissingSkus(page); }
function renderMissingSkusTable(skus, data) { function renderMissingSkusTable(skus, data) {
const tbody = document.getElementById('missingBody'); const tbody = document.getElementById('missingBody');
if (data) { const mobileList = document.getElementById('missingMobileList');
document.getElementById('missingInfo').textContent =
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
}
if (!skus || skus.length === 0) { if (!skus || skus.length === 0) {
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' : const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit'; skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`; 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; return;
} }
tbody.innerHTML = skus.map(s => { tbody.innerHTML = skus.map(s => {
const statusBadge = s.resolved const trAttrs = !s.resolved
? '<span class="badge bg-success">Rezolvat</span>' ? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
: '<span class="badge bg-warning">Nerezolvat</span>'; : '';
return `<tr${trAttrs}>
let firstCustomer = '-'; <td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
try {
const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore */ }
const orderCount = s.order_count != null ? s.order_count : '-';
return `<tr class="${s.resolved ? 'table-light' : ''}">
<td><code>${esc(s.sku)}</code></td> <td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td> <td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td>${esc(orderCount)}</td>
<td><small>${esc(firstCustomer)}</small></td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
<td>${statusBadge}</td>
<td> <td>
${!s.resolved ${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza"> ? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
@@ -225,31 +230,33 @@ function renderMissingSkusTable(skus, data) {
</td> </td>
</tr>`; </tr>`;
}).join(''); }).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) { function renderPagination(data) {
const ul = document.getElementById('paginationControls'); const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
const total = data.pages || 1; const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
const page = data.page || 1; const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
if (total <= 1) { ul.innerHTML = ''; return; } const top = document.getElementById('skusPagTop');
const bot = document.getElementById('skusPagBottom');
let html = ''; if (top) top.innerHTML = pagHtml;
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}"> if (bot) bot.innerHTML = pagHtml;
<a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
const range = 2;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
} else if (i === page - range - 1 || i === page + range + 1) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
}
}
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
ul.innerHTML = html;
} }
// ── Multi-CODMAT Map Modal ─────────────────────── // ── Multi-CODMAT Map Modal ───────────────────────
@@ -384,9 +391,5 @@ function exportMissingCsv() {
window.location.href = '/api/validate/missing-skus-csv'; window.location.href = '/api/validate/missing-skus-csv';
} }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
</script> </script>
{% endblock %} {% endblock %}