feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts

Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

View File

@@ -0,0 +1,683 @@
/* ==========================================================================
POS-GLASS.CSS — Pixel-Perfect glassmorphism overlay for Nexus POS
Load AFTER tokens.css. Applies glass effects, glow, 3D buttons,
and animations to all POS pages without modifying inline styles.
========================================================================== */
/* ── Hidden scrollbar (global) ── */
html { scrollbar-width: none; }
html::-webkit-scrollbar { width: 0; }
/* ── Smooth font rendering ── */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ==========================================================================
SIDEBAR — Glass treatment
========================================================================== */
.sidebar,
.pos-sidebar {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border) !important;
}
.sidebar__logo {
position: relative;
}
.sidebar__logo-text {
position: relative;
}
/* Glow under logo text */
.sidebar__logo-text::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 2px;
background: var(--gradient-accent);
border-radius: 1px;
opacity: 0.4;
filter: blur(2px);
}
/* Nav items — hover glow */
.sidebar__nav a,
.sidebar__nav-item,
.sidebar .nav-item {
transition: all 0.25s var(--ease-out) !important;
border-radius: var(--radius-md);
}
.sidebar__nav a:hover,
.sidebar__nav-item:hover,
.sidebar .nav-item:hover {
box-shadow: 0 0 12px var(--glow-color-soft);
}
.sidebar__nav a.active,
.sidebar__nav-item.active,
.sidebar .nav-item.active {
box-shadow: 0 0 16px var(--glow-color-soft), inset 0 0 0 1px var(--glass-border);
}
/* ==========================================================================
THEME BAR — Glass
========================================================================== */
.theme-bar {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--glass-border) !important;
}
/* ==========================================================================
CARDS — Glass with glow hover
========================================================================== */
.kpi-card,
.table-card,
.card,
.stat-card,
.chart-card,
.alert-card,
.config-card,
.fleet-card,
.report-card,
.invoice-card,
.customer-card,
.panel {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border) !important;
transition: all 0.3s var(--ease-out) !important;
position: relative;
overflow: hidden;
}
/* Accent top-line on hover */
.kpi-card::before,
.table-card::before,
.chart-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--gradient-accent);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.4s var(--ease-out);
z-index: 1;
}
.kpi-card:hover::before,
.table-card:hover::before,
.chart-card:hover::before {
transform: scaleX(1);
}
.kpi-card:hover,
.table-card:hover,
.card:hover,
.stat-card:hover,
.chart-card:hover,
.config-card:hover,
.fleet-card:hover,
.report-card:hover {
border-color: var(--color-border-accent) !important;
box-shadow: 0 4px 20px var(--glow-color-soft);
}
/* KPI card accent bar — add glow */
.kpi-card__accent-bar {
box-shadow: 0 0 8px var(--glow-color-soft);
}
/* ==========================================================================
BUTTONS — 3D depth effect
========================================================================== */
/* Primary buttons */
.btn--primary,
button.primary,
.btn-primary,
input[type="submit"],
button[type="submit"] {
background: var(--gradient-accent) !important;
border: none !important;
box-shadow: 0 3px 0 var(--color-primary-active),
0 4px 10px var(--glow-color-soft) !important;
transition: all 0.25s var(--ease-out) !important;
position: relative;
overflow: hidden;
}
.btn--primary:hover,
button.primary:hover,
.btn-primary:hover,
input[type="submit"]:hover,
button[type="submit"]:hover {
transform: translateY(-1px);
box-shadow: 0 4px 0 var(--color-primary-active),
0 8px 20px var(--glow-color) !important;
}
.btn--primary:active,
button.primary:active,
.btn-primary:active,
input[type="submit"]:active,
button[type="submit"]:active {
transform: translateY(1px);
box-shadow: 0 1px 0 var(--color-primary-active) !important;
}
/* Ghost / secondary buttons — glass */
.btn--ghost,
.btn--secondary,
.btn-secondary,
.btn-ghost,
button.secondary {
background: var(--glass-bg) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--glass-border) !important;
transition: all 0.25s var(--ease-out) !important;
}
.btn--ghost:hover,
.btn--secondary:hover,
.btn-secondary:hover,
.btn-ghost:hover,
button.secondary:hover {
border-color: var(--color-border-accent) !important;
box-shadow: 0 0 16px var(--glow-color-soft);
}
/* ==========================================================================
INPUTS — Glass with focus glow
========================================================================== */
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="date"],
input[type="url"],
textarea,
select,
.search-input,
.filter-input {
background: var(--glass-bg) !important;
border: 1px solid var(--glass-border) !important;
transition: all 0.25s var(--ease-out) !important;
}
input[type="text"]:focus,
input[type="number"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="search"]:focus,
input[type="tel"]:focus,
input[type="date"]:focus,
input[type="url"]:focus,
textarea:focus,
select:focus,
.search-input:focus,
.filter-input:focus {
border-color: var(--color-border-focus) !important;
box-shadow: 0 0 0 3px var(--glow-color-soft), 0 0 16px var(--glow-color-soft) !important;
outline: none;
}
/* ==========================================================================
TABLES — Subtle glass rows
========================================================================== */
table thead th {
background: var(--glass-bg) !important;
backdrop-filter: blur(8px);
font-family: var(--font-mono);
font-size: var(--text-caption);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
}
table tbody tr {
transition: all 0.2s ease !important;
}
table tbody tr:hover {
background: var(--glass-highlight) !important;
box-shadow: inset 0 0 0 1px var(--glass-border);
}
/* ==========================================================================
MODALS — Glass overlay + glass content
========================================================================== */
.modal-overlay,
.overlay,
.modal-backdrop {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal,
.modal-content,
.modal-dialog,
.dialog {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border) !important;
box-shadow: 0 24px 48px rgba(0,0,0,0.3) !important;
}
/* ==========================================================================
TABS — Glass active state
========================================================================== */
.tab,
.tab-btn,
.tabs button {
transition: all 0.25s var(--ease-out) !important;
border-radius: var(--radius-md);
}
.tab.active,
.tab-btn.active,
.tabs button.active {
background: var(--color-primary-muted) !important;
box-shadow: 0 0 12px var(--glow-color-soft);
border-color: var(--color-border-accent) !important;
}
/* ==========================================================================
BADGES / TAGS — Subtle glow
========================================================================== */
.badge,
.tag,
.status-badge,
.pill {
backdrop-filter: blur(4px);
transition: all 0.2s ease;
}
/* ==========================================================================
SCROLL REVEAL — Available for any POS page that wants it
========================================================================== */
.nx-reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}
.nx-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; }
.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; }
.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; }
.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; }
.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; }
.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; }
/* ==========================================================================
TOAST / NOTIFICATIONS — Glass
========================================================================== */
.toast,
.notification,
.snackbar,
.alert {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border) !important;
}
/* ==========================================================================
DROPDOWN / POPOVER — Glass
========================================================================== */
.dropdown-menu,
.popover,
.autocomplete-list,
.suggestion-list {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border) !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.2) !important;
}
/* ==========================================================================
STATUS BAR (POS) — Glass
========================================================================== */
.status-bar,
.pos-status-bar {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--glass-border) !important;
}
/* ==========================================================================
LOADING SPINNER — Glow animation
========================================================================== */
.spinner,
.loading-spinner {
animation: nx-glow-pulse 1.5s ease-in-out infinite;
}
/* ==========================================================================
ANIMATIONS — Available keyframes
========================================================================== */
@keyframes pos-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Apply subtle entry animation to main content area */
.content,
.main-content,
main {
animation: pos-fade-in 0.4s var(--ease-out) both;
}
/* ==========================================================================
DASHED BORDER ACCENTS (Pixel-Perfect style)
========================================================================== */
.section-divider,
hr {
border: none;
border-top: 1px dashed var(--glass-border);
margin: var(--space-4) 0;
}
/* ==========================================================================
TABLET RESPONSIVE — Adaptive layout for 768px-1024px screens
Applied globally to all POS pages via pos-glass.css.
Targets iPad (768×1024), Android tablets (800×1280), and similar.
========================================================================== */
/* ── Tablet portrait (768-1023px) — sidebar collapses, grids reflow ── */
@media (max-width: 1023px) {
/* Sidebar collapses to an overlay drawer */
.sidebar,
.pos-sidebar {
position: fixed !important;
top: 0 !important;
left: 0 !important;
bottom: 0 !important;
z-index: var(--z-modal) !important;
transform: translateX(-100%) !important;
transition: transform 0.3s var(--ease-out) !important;
width: 260px !important;
}
.sidebar.open,
.pos-sidebar.open {
transform: translateX(0) !important;
box-shadow: 0 0 40px rgba(0,0,0,0.3) !important;
}
.sidebar-overlay {
display: none !important;
position: fixed !important;
inset: 0 !important;
z-index: calc(var(--z-modal) - 1) !important;
background: rgba(0,0,0,0.5) !important;
}
.sidebar-overlay.open {
display: block !important;
}
/* App shell: full width when sidebar is hidden */
.app-shell {
flex-direction: column !important;
}
.app-shell > main,
.app-shell > .main-content,
.app-shell > .content,
.main-content,
.content {
margin-left: 0 !important;
width: 100% !important;
}
/* Show hamburger button */
.hamburger-btn {
display: flex !important;
}
/* Touch-friendly targets — minimum 44px tap area */
button,
.btn,
.nav-card,
.tab-btn,
.tab,
.part-card,
.search-result-item,
table tbody tr,
.kpi-card {
min-height: 44px;
}
/* Larger text for readability on tablets */
.kpi-card__value {
font-size: 1.5rem !important;
}
/* Grid reflow: 2 columns instead of 3-4 */
.kpi-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.nav-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
/* Tables: horizontal scroll wrapper on narrow screens */
.table-wrap,
.table-card {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
/* POS-specific: if the POS has a side panel (cart), stack vertically */
.pos-layout {
flex-direction: column !important;
}
.pos-layout .pos-cart,
.pos-layout .cart-panel {
width: 100% !important;
max-width: 100% !important;
height: auto !important;
max-height: 40vh !important;
}
/* Content headers: tighter padding */
.content-header,
.header,
.page-header {
padding: var(--space-3) var(--space-4) !important;
}
/* Search bar: full width */
.search-bar,
.search-wrapper {
width: 100% !important;
max-width: 100% !important;
}
/* Mode toggle: slightly larger buttons for touch */
.mode-toggle button {
padding: 6px 14px !important;
font-size: 12px !important;
}
/* Vehicle selector dropdowns: stack on smaller tablets */
.vehicle-selector__inner,
.vehicle-selector .vs-group {
flex-wrap: wrap !important;
}
.vehicle-selector .vs-arrow {
display: none !important;
}
.vehicle-selector .vs-select {
min-width: 130px !important;
}
}
/* ── Phone portrait (< 768px) — single column, max simplification ── */
@media (max-width: 767px) {
.sidebar {
width: 85vw !important;
max-width: 300px !important;
}
.kpi-grid,
.nav-grid,
.results-grid {
grid-template-columns: 1fr !important;
}
.kpi-card__value {
font-size: 1.3rem !important;
}
/* Stack the mode toggle buttons vertically if tight */
.mode-toggle {
flex-wrap: wrap !important;
}
/* Hide non-essential UI to save space */
.header__store-badge,
.vs-vin-divider {
display: none !important;
}
/* Full-width modals */
.modal-content {
max-width: 95vw !important;
margin: var(--space-3) !important;
padding: var(--space-4) !important;
}
/* Tables: force readable font size */
table {
font-size: 12px !important;
}
table th,
table td {
padding: var(--space-2) var(--space-2) !important;
}
}
/* ── Landscape tablet (height < 600px with wide screen) ── */
@media (max-height: 600px) and (min-width: 768px) {
/* Reduce vertical padding for landscape tablet use */
.kpi-grid {
gap: var(--space-2) !important;
}
.dashboard,
.main-content,
.content {
padding: var(--space-3) !important;
}
}
/* ── Touch device hints ── */
@media (hover: none) and (pointer: coarse) {
/* Remove hover-only effects on touch devices — they cause sticky states */
.kpi-card:hover,
.nav-card:hover,
.part-card:hover,
.table-card:hover,
.card:hover {
transform: none !important;
}
/* Larger touch targets for interactive elements */
.sidebar__nav a,
.sidebar__nav-item,
.sidebar .nav-item {
padding: 12px 16px !important;
min-height: 48px !important;
display: flex !important;
align-items: center !important;
}
/* Scroll momentum on iOS */
.table-wrap,
.main-content,
.content,
.parts-grid,
.nav-grid {
-webkit-overflow-scrolling: touch;
}
/* Disable text selection on buttons (prevents accidental blue highlight on long tap) */
button,
.btn,
.nav-card,
.tab-btn {
-webkit-user-select: none;
user-select: none;
}
}
/* ==========================================================================
PRINT — Disable glass effects for printing
========================================================================== */
@media print {
.sidebar,
.theme-bar,
.kpi-card,
.table-card,
.card,
.modal,
.modal-content,
table thead th,
input,
select,
textarea {
background: #fff !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
border-color: #ccc !important;
color: #000 !important;
}
}

View File

@@ -558,6 +558,69 @@
}
/* ==========================================================================
GLASSMORPHISM TOKENS
========================================================================== */
[data-theme="industrial"] {
--glass-bg: rgba(26, 26, 26, 0.70);
--glass-bg-strong: rgba(26, 26, 26, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 16px;
--glass-highlight: rgba(245, 166, 35, 0.06);
--glow-color: rgba(245, 166, 35, 0.40);
--glow-color-soft: rgba(245, 166, 35, 0.15);
--glow-color-strong: rgba(245, 166, 35, 0.60);
--gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%);
--gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%);
--canvas-grid-color: rgba(255, 255, 255, 0.06);
--canvas-star-color: rgba(245, 166, 35, 0.30);
--canvas-glow-color: rgba(245, 166, 35, 0.08);
}
[data-theme="modern"] {
--glass-bg: rgba(248, 249, 255, 0.70);
--glass-bg-strong: rgba(248, 249, 255, 0.85);
--glass-border: rgba(26, 26, 46, 0.08);
--glass-blur: 16px;
--glass-highlight: rgba(255, 107, 53, 0.04);
--glow-color: rgba(255, 107, 53, 0.35);
--glow-color-soft: rgba(255, 107, 53, 0.12);
--glow-color-strong: rgba(255, 107, 53, 0.55);
--gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%);
--gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%);
--canvas-grid-color: rgba(26, 26, 46, 0.05);
--canvas-star-color: rgba(255, 107, 53, 0.20);
--canvas-glow-color: rgba(255, 107, 53, 0.06);
}
/* ==========================================================================
ANIMATION KEYFRAMES
========================================================================== */
@keyframes nx-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes nx-glow-pulse {
0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); }
50% { box-shadow: 0 0 40px var(--glow-color); }
}
@keyframes nx-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ==========================================================================
END OF TOKENS FILE
nexus-autoparts-design/tokens/tokens.css

View File

@@ -390,7 +390,37 @@ const Accounting = (() => {
// ---- Exportar placeholder ----
function exportarContabilidad() {
alert('Exportar: proximamente');
// Find the first visible table in the active accounting tab and export as CSV
var tables = document.querySelectorAll('table');
var table = null;
for (var i = 0; i < tables.length; i++) {
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
table = tables[i];
break;
}
}
if (!table) {
alert('No hay datos para exportar en la vista actual.');
return;
}
var rows = [];
var ths = table.querySelectorAll('thead th');
if (ths.length) {
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
}
table.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
});
if (rows.length <= 1) { alert('Sin datos para exportar.'); return; }
var csv = rows.join('\n');
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'contabilidad_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
URL.revokeObjectURL(url);
}
// ---- Nueva Poliza modal ----

View File

@@ -49,15 +49,77 @@
// ─── Navigation State ───
var nav = {
level: 'brands', // brands|models|years|engines|categories|groups|parts
level: 'brands', // brands|models|years|engines|categories|groups|part_types|parts
brand: null, // {id, name}
model: null, // {id, name}
year: null, // {id, year}
engine: null, // {id_mye, name}
// OEM mode (TecDoc) navigation state — integer IDs
category: null, // {id, name}
group: null, // {id, name}
partType: null, // {slug, name} ← 3rd-level subcategory (Nexpart-style)
// Local mode (Nexpart) navigation state — string slugs.
// These live in parallel with category/group/partType so transitioning
// between modes doesn't trash the other branch's state.
nxGroup: null, // {slug, name} ← top-level Nexpart group (14 total)
nxSubgroup: null, // {slug, name} ← Nexpart subgroup
nxPartType: null, // {slug, name} ← Nexpart part type (3rd level)
};
// ─── Catalog mode (OEM / Local) ───
var catalogMode = (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem');
function updateModeToggleUI() {
var btns = document.querySelectorAll('#modeToggle button');
btns.forEach(function (b) {
if (b.getAttribute('data-mode') === catalogMode) {
b.classList.add('is-active');
} else {
b.classList.remove('is-active');
}
});
}
function setCatalogMode(mode) {
if (mode !== 'oem' && mode !== 'local' && mode !== 'supplies') return;
if (mode === catalogMode) return;
catalogMode = mode;
localStorage.setItem('catalog_mode', mode);
updateModeToggleUI();
// Clear category-and-below state regardless of mode
nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
currentPage = 1;
if (mode === 'supplies') {
// Supplies mode skips the vehicle chain entirely.
// Clear the vehicle state for visual clarity and go directly
// to the Shop Supplies top-level group list.
try { vsClearAll(); } catch (e) {}
nav.brand = nav.model = nav.year = nav.engine = null;
nav.level = 'categories';
loadShopSuppliesGroups();
return;
}
// OEM/Local: smart reset — if the user already picked a vehicle,
// stay at the categories level. Otherwise reset to brand selection.
var hasVehicle = !!(nav.engine && nav.engine.id_mye);
if (hasVehicle) {
nav.level = 'categories';
loadCategoriesForMode();
return;
}
try { vsClearAll(); } catch (e) {}
nav.level = 'brands';
nav.brand = nav.model = nav.year = nav.engine = null;
loadBrands();
}
var currentPage = 1;
var currentDetailPart = null;
var detailQty = 1;
@@ -82,6 +144,10 @@
nav.engine = e.state.engine;
nav.category = e.state.category;
nav.group = e.state.group;
nav.partType = e.state.partType || null;
nav.nxGroup = e.state.nxGroup || null;
nav.nxSubgroup = e.state.nxSubgroup || null;
nav.nxPartType = e.state.nxPartType || null;
currentPage = e.state.page || 1;
// Reload the correct level
@@ -89,8 +155,16 @@
else if (nav.level === 'models') loadModels();
else if (nav.level === 'years') loadYears();
else if (nav.level === 'engines') loadEngines();
else if (nav.level === 'categories') loadCategories();
else if (nav.level === 'groups') loadGroups();
// When restoring from history, dispatch between OEM and Nexpart
// based on which branch of state is populated — this survives
// toggle changes made mid-session.
else if (nav.level === 'categories') loadCategoriesForMode();
else if (nav.level === 'groups') {
if (nav.nxGroup) loadNexpartSubgroups(); else loadGroups();
}
else if (nav.level === 'part_types') {
if (nav.nxSubgroup) loadNexpartPartTypes(); else loadPartTypes();
}
else if (nav.level === 'parts') loadParts(currentPage);
else loadBrands();
@@ -151,8 +225,19 @@
if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' });
if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' });
if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' });
if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.group) parts.push({ label: nav.group.name, action: null });
// The category/group/part_type trio is rendered from EITHER the Nexpart
// branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch (category/
// group/partType), depending on which is populated. Only one branch
// should be active at a time after a navigation reset.
if (nav.nxGroup) parts.push({ label: nav.nxGroup.name, action: 'loadNxSubgroups' });
else if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.nxSubgroup) parts.push({ label: nav.nxSubgroup.name, action: 'loadNxPartTypes' });
else if (nav.group) parts.push({ label: nav.group.name, action: 'loadPartTypes' });
if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null });
else if (nav.partType) parts.push({ label: nav.partType.name, action: null });
var html = '';
for (var i = 0; i < parts.length; i++) {
@@ -173,8 +258,12 @@
else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); }
else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); }
else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategories(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategoriesForMode(); }
else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); }
else if (action === 'loadPartTypes') { resetNavFrom('part_types'); loadPartTypes(); }
// Nexpart-branch breadcrumb actions
else if (action === 'loadNxSubgroups') { resetNavFrom('groups'); loadNexpartSubgroups(); }
else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); }
});
});
}
@@ -182,17 +271,33 @@
function resetNav() {
nav.level = 'brands';
pushNavState();
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null;
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
}
function resetNavFrom(level) {
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts'];
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts'];
var idx = levels.indexOf(level);
if (idx <= 0) { resetNav(); return; }
nav.level = level;
var keys = [null, 'model', 'year', 'engine', 'category', 'group', null];
// For each level, the corresponding state key(s) to clear.
// In Local mode, 'categories' clears nxGroup, 'groups' clears nxSubgroup, etc.
// We clear BOTH mode-specific keys at each level so a mode switch mid-navigation
// is always clean.
var keys = [
null, // brands (nothing to clear above)
['model'], // models
['year'], // years
['engine'], // engines
['category', 'nxGroup'], // categories ← both OEM + Nexpart
['group', 'nxSubgroup'], // groups ← both OEM + Nexpart
['partType', 'nxPartType'], // part_types ← both OEM + Nexpart
null, // parts
];
for (var i = idx; i < keys.length; i++) {
if (keys[i]) nav[keys[i]] = null;
if (!keys[i]) continue;
var ks = keys[i];
for (var j = 0; j < ks.length; j++) nav[ks[j]] = null;
}
}
@@ -221,7 +326,7 @@
setupLevelFilter(true);
showLoading();
apiFetch(API + '/brands').then(function (data) {
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
if (!data) {
@@ -317,7 +422,7 @@
if (data.data.length === 1) {
var e = data.data[0];
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
loadCategories();
loadCategoriesForMode();
return;
}
@@ -333,7 +438,7 @@
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
loadCategories();
loadCategoriesForMode();
});
});
});
@@ -389,32 +494,345 @@
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name };
nav.partType = null; // reset deeper levels
loadPartTypes();
});
});
});
}
// ─── Part Types (3rd subcategory level — Nexpart-style) ───
function loadPartTypes() {
nav.level = 'part_types';
nav.partType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
setupLevelFilter(true);
showLoading();
apiFetch(API + '/part-types?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
// No part types? Skip directly to all parts in the group.
loadParts(1);
return;
}
// Single part type? Skip the picker — go straight to parts.
if (data.data.length === 1) {
var only = data.data[0];
nav.partType = { slug: only.slug, name: only.name };
loadParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" data-pt-slug="' + esc(pt.slug) + '" data-pt-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.partType = { slug: this.dataset.ptSlug, name: this.dataset.ptName };
loadParts(1);
});
});
});
}
function loadParts(page) {
// ═══════════════════════════════════════════════════════════════════
// NEXPART (Local mode) — parallel navigation functions
// ═══════════════════════════════════════════════════════════════════
// These run in parallel to loadCategories / loadGroups / loadPartTypes
// and are only invoked when catalogMode === 'local'. They share the
// same DOM hooks (navGrid, breadcrumb, levelTitle) but fetch from the
// Nexpart-filtered endpoints and store state in nav.nxGroup / nxSubgroup
// / nxPartType instead of nav.category / group / partType.
function loadCategoriesForMode() {
// Dispatcher — called by every place that used to call loadCategories()
if (catalogMode === 'local') {
loadNexpartCategories();
} else {
loadCategories();
}
}
function loadNexpartCategories() {
nav.level = 'categories';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = 'Categorias (Local)';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/categories?mode=local&mye_id=' + nav.engine.id_mye).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin categorias Local', 'Ninguna parte de este vehiculo mapea al catalogo Local.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (c) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(c.slug) + '" data-name="' + esc(c.name) + '">' +
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
// Reset deeper Nexpart state so a re-click always goes to
// a clean subgroup list.
nav.nxSubgroup = null;
nav.nxPartType = null;
loadNexpartSubgroups();
});
});
});
}
function loadNexpartSubgroups() {
nav.level = 'groups';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxGroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/groups?mode=local&mye_id=' + nav.engine.id_mye
+ '&category_slug=' + encodeURIComponent(nav.nxGroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin subcategorias', 'No hay subcategorias en ' + nav.nxGroup.name);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (s) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxPartType = null;
loadNexpartPartTypes();
});
});
});
}
function loadNexpartPartTypes() {
nav.level = 'part_types';
nav.nxPartType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxSubgroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/part-types?mode=local&mye_id=' + nav.engine.id_mye
+ '&group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin tipos de parte', 'No hay tipos de parte en ' + nav.nxSubgroup.name);
return;
}
// Single part type? Auto-drill-down to parts (UX shortcut).
if (data.data.length === 1) {
var only = data.data[0];
nav.nxPartType = { slug: only.slug, name: only.name };
loadParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
loadParts(1);
});
});
});
}
// ═══════════════════════════════════════════════════════════════════
// SHOP SUPPLIES (Supplies mode) — vehicle-independent
// ═══════════════════════════════════════════════════════════════════
// Uses nav.nxGroup / nav.nxSubgroup / nav.nxPartType for state (reuses
// the Nexpart slot because Supplies is a subset of the Nexpart taxonomy)
// but calls a different set of endpoints (/shop-supplies/*) that don't
// require an mye_id.
function loadShopSuppliesGroups() {
nav.level = 'categories';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = 'Shop Supplies (sin vehiculo)';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/shop-supplies/groups').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin supplies', 'No hay grupos de Shop Supplies disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (g) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(g.slug) + '" data-name="' + esc(g.name) + '">' +
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
'<div class="nav-card__count">' + g.subgroup_count + ' subgrupos</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxSubgroup = null;
nav.nxPartType = null;
loadShopSuppliesSubgroups();
});
});
});
}
function loadShopSuppliesSubgroups() {
nav.level = 'groups';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxGroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/shop-supplies/subgroups?group_slug=' + encodeURIComponent(nav.nxGroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin subgrupos', 'No hay subgrupos con partes disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (s) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxPartType = null;
loadShopSuppliesPartTypes();
});
});
});
}
function loadShopSuppliesPartTypes() {
nav.level = 'part_types';
nav.nxPartType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxSubgroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/shop-supplies/part-types'
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin tipos', 'No hay tipos de parte en este subgrupo.');
return;
}
// Single part type? Skip the picker.
if (data.data.length === 1) {
var only = data.data[0];
nav.nxPartType = { slug: only.slug, name: only.name };
loadShopSuppliesParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
loadShopSuppliesParts(1);
});
});
});
}
function loadShopSuppliesParts(page) {
nav.level = 'parts';
pushNavState();
currentPage = page || 1;
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
levelTitle.textContent = nav.nxPartType.name;
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
var url = API + '/shop-supplies/parts'
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug)
+ '&part_type_slug=' + encodeURIComponent(nav.nxPartType.slug)
+ '&page=' + currentPage + '&per_page=30';
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin partes', 'No hay partes en este tipo.');
return;
}
// Reuse the same aftermarket-styled rendering as Local mode.
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.bodega_count > 0) {
if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
@@ -424,10 +842,123 @@
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
return '<article class="part-card" role="listitem" data-part-id="' + p.id_part + '">' +
var tierClass = p.priority_tier === 1 ? ' part-card--tier1' : (p.priority_tier === 2 ? ' part-card--tier2' : '');
var manuBadge = '';
if (p.manufacturer) {
var tierStar = p.priority_tier === 1 ? '<span class="manu-tier">★</span>' : '';
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
}
var skuLine = p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number || '') + '</span>'
: esc(p.oem_part_number || '');
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
'<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>' +
stockBadge +
'</div>' +
'</article>';
}).join('');
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
card.addEventListener('click', function () {
openPartDetail(parseInt(this.dataset.partId));
});
});
if (data.pagination) renderPagination(data.pagination);
});
}
function loadParts(page) {
nav.level = 'parts';
pushNavState();
currentPage = page || 1;
updateBreadcrumb();
// Title: Nexpart part type > TecDoc part type > TecDoc group
if (nav.nxPartType) {
levelTitle.textContent = nav.nxPartType.name;
} else if (nav.partType) {
levelTitle.textContent = nav.partType.name;
} else if (nav.group) {
levelTitle.textContent = nav.group.name;
} else {
levelTitle.textContent = 'Partes';
}
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
// Build the URL based on which navigation branch the user took.
// Nexpart branch uses slug-based params; OEM branch uses integer ids.
var url;
if (nav.nxGroup && nav.nxSubgroup && nav.nxPartType) {
url = API + '/parts?mode=local'
+ '&mye_id=' + nav.engine.id_mye
+ '&page=' + currentPage + '&per_page=30'
+ '&nexpart_group=' + encodeURIComponent(nav.nxGroup.slug)
+ '&nexpart_subgroup=' + encodeURIComponent(nav.nxSubgroup.slug)
+ '&nexpart_part_type=' + encodeURIComponent(nav.nxPartType.slug);
} else {
var ptParam = nav.partType ? '&part_type=' + encodeURIComponent(nav.partType.slug) : '';
url = API + '/parts?mye_id=' + nav.engine.id_mye
+ '&group_id=' + nav.group.id
+ '&page=' + currentPage + '&per_page=30'
+ '&mode=' + catalogMode
+ ptParam;
}
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
var isLocal = (catalogMode === 'local');
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
// Stock badge — prefer tenant stock, then warehouse network, else fallback
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
// Local-mode extras: manufacturer badge + priority tier indicator
var manuBadge = '';
var tierClass = '';
if (isLocal && p.manufacturer) {
var tierLabel = '';
if (p.priority_tier === 1) { tierClass = ' part-card--tier1'; tierLabel = '★'; }
else if (p.priority_tier === 2) { tierClass = ' part-card--tier2'; tierLabel = ''; }
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' +
(tierLabel ? '<span class="manu-tier">' + tierLabel + '</span>' : '') + '</div>';
}
// SKU to show: aftermarket part_number in local mode, OEM number otherwise
var skuLine = isLocal && p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span>'
: esc(p.oem_part_number);
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
@@ -618,11 +1149,148 @@
// ─── SMART SEARCH ───
var searchTimeout = null;
// ═══════════════════════════════════════════════════════════════════
// SMART SEARCH — auto-detect VIN / plate / part number / keyword
// ═══════════════════════════════════════════════════════════════════
// Returns: 'vin' | 'plate' | 'part_number' | 'keyword'
function detectQueryType(raw) {
if (!raw) return 'keyword';
var q = raw.trim();
// Strip common separators for detection (VINs/parts rarely contain spaces)
var compact = q.replace(/[\s\-]/g, '').toUpperCase();
// VIN: exactly 17 chars alphanumeric, no I/O/Q
if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin';
// Mexican license plate: 3 letters + 3-4 digits (with/without hyphen)
if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate';
// Part-number heuristic. Rules designed to avoid false positives on
// natural-language Spanish/English queries:
// 1. Original query must NOT contain lowercase letters. Real part
// numbers are always uppercase ("4G0-857-951-A"); keywords aren't.
// 2. No natural-language words allowed (para, de, con, for, the, etc.)
// 3. Either has a dash/slash separator, or is a solid alphanumeric blob.
var hasLowercase = /[a-z]/.test(q);
if (hasLowercase) return 'keyword';
// Block queries that contain a year-like 4-digit number alongside
// other tokens — those are "PART 2018" style vehicle refs, not parts.
var tokens = q.split(/\s+/);
var hasYear = tokens.some(function (t) { return /^(19|20)\d{2}$/.test(t); });
if (hasYear && tokens.length > 1) return 'keyword';
var qUpper = q.toUpperCase();
// Dashed/slashed part number: "4G0-857-951-A", "BP-1234"
if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) {
return 'part_number';
}
// Space-separated part number (rare but real, e.g. BOSCH "0 986 4B7 013")
if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) {
return 'part_number';
}
// Solid alphanumeric blob 8+ chars with both letters+digits
if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) {
return 'part_number';
}
return 'keyword';
}
// Hint badge shown next to the search input. Injected lazily so we don't
// need to touch the HTML.
var searchHint = null;
function ensureSearchHint() {
if (searchHint) return searchHint;
searchHint = document.createElement('div');
searchHint.id = 'searchHint';
searchHint.style.cssText =
'position:absolute;top:100%;left:0;margin-top:4px;padding:3px 10px;' +
'background:var(--color-primary-muted);color:var(--color-text-accent);' +
'font-size:var(--text-caption);font-weight:var(--font-weight-semibold);' +
'border:1px dashed var(--color-border-accent);border-radius:var(--radius-sm);' +
'white-space:nowrap;pointer-events:none;z-index:10;display:none;';
searchInput.parentElement.appendChild(searchHint);
return searchHint;
}
function updateSearchHint(type) {
var hint = ensureSearchHint();
var labels = {
vin: '🚗 VIN detectado — decodificando',
plate: '🔖 Placa detectada — consultando registro',
part_number: '🔩 Numero de parte detectado',
keyword: null,
};
var label = labels[type];
if (!label) {
hint.style.display = 'none';
} else {
hint.textContent = label;
hint.style.display = '';
}
}
// Smart dispatcher — decides which endpoint to call based on input type.
function runSmartSearch(q) {
var type = detectQueryType(q);
if (type === 'vin') {
// Use the existing VIN decoder flow
try { decodeVinWithValue(q); } catch (e) { runSearch(q); }
return;
}
if (type === 'plate') {
// Use the existing plate lookup flow — assume default state MX
try { lookupPlateWithValue(q); } catch (e) { runSearch(q); }
return;
}
// For part_number and keyword, both go through the existing /search
// endpoint (which supports full-text + OEM number search).
runSearch(q);
}
// Thin wrappers around existing VIN/plate handlers — they usually read
// from their own input fields; these set the field and trigger.
function decodeVinWithValue(vin) {
var vinInput = document.getElementById('vinInput');
if (vinInput) {
vinInput.value = vin;
if (typeof decodeVin === 'function') decodeVin();
else runSearch(vin);
} else {
runSearch(vin); // fallback
}
}
function lookupPlateWithValue(plate) {
var plateInput = document.getElementById('plateInput');
if (plateInput) {
plateInput.value = plate.toUpperCase();
if (typeof lookupPlate === 'function') lookupPlate();
else runSearch(plate);
} else {
runSearch(plate); // fallback
}
}
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
var q = this.value.trim();
// Live type detection for the hint (runs on every keystroke)
updateSearchHint(q.length >= 3 ? detectQueryType(q) : null);
if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; }
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
// For keyword queries, keep the debounced dropdown preview.
// For VIN/plate/part-number, wait for Enter — they're one-shot lookups.
var type = detectQueryType(q);
if (type === 'keyword') {
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
}
});
searchInput.addEventListener('keydown', function (e) {
@@ -630,10 +1298,11 @@
e.preventDefault();
clearTimeout(searchTimeout);
var q = this.value.trim();
if (q.length >= 2) runSearch(q);
if (q.length >= 2) runSmartSearch(q);
}
if (e.key === 'Escape') {
searchDropdown.classList.remove('is-visible');
updateSearchHint(null);
}
});
@@ -906,7 +1575,7 @@
// Load brands filtered by year
vsBrand.disabled = false;
apiFetch(API + '/brands?year_id=' + yearId).then(function (data) {
apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) {
var brands = data.data || data;
if (!brands) return;
vsBrand.innerHTML = '<option value="">Marca...</option>';
@@ -980,7 +1649,7 @@
nav.level = 'categories';
pushNavState();
loadCategories();
loadCategoriesForMode();
// Scroll to catalog content
setTimeout(function () {
@@ -999,7 +1668,9 @@
vsEngine.disabled = true;
vsClear.style.display = 'none';
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; currentPage = 1;
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; nav.partType = null;
nav.nxGroup = null; nav.nxSubgroup = null; nav.nxPartType = null;
currentPage = 1;
pushNavState();
loadBrands();
}
@@ -1231,10 +1902,12 @@
decodeVin: decodeVin,
togglePlate: togglePlate,
lookupPlate: lookupPlate,
setMode: setCatalogMode,
};
// ─── INIT ───
renderCart();
updateModeToggleUI();
vsLoadYears();
loadBrands();

View File

@@ -256,7 +256,7 @@ const Config = (() => {
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
+ '<td><button class="btn btn--ghost btn--sm" disabled>Editar</button></td>'
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
+ '</tr>';
});
@@ -265,8 +265,21 @@ const Config = (() => {
}
async function saveEmployee(data) {
var res = await fetch(API + '/employees', {
method: 'POST',
// Check if we're editing (modal has editId) or creating
var modal = document.getElementById('employee-modal');
var editId = modal ? modal.dataset.editId : null;
var url = API + '/employees';
var method = 'POST';
if (editId) {
url = API + '/employees/' + editId;
method = 'PUT';
// Clear the edit marker so next use is a fresh create
delete modal.dataset.editId;
}
var res = await fetch(url, {
method: method,
headers: headers(),
body: JSON.stringify(data)
});
@@ -302,6 +315,95 @@ const Config = (() => {
if (el) el.value = v || '';
}
function getVal(id) {
var el = document.getElementById(id);
return el ? el.value.trim() : '';
}
async function editEmployee(empId) {
if (!checkAuth()) return;
// Find the employee in the loaded data by re-fetching
try {
var res = await fetch(API + '/employees', { headers: headers() });
if (!res.ok) throw new Error('Failed to load employees');
var json = await res.json();
var emp = (json.data || []).find(function(e) { return e.id === empId; });
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
// Pre-fill the "new employee" modal with existing data for editing
setVal('new-emp-name', emp.name);
setVal('new-emp-email', emp.email || '');
var roleSelect = document.getElementById('new-emp-role');
if (roleSelect) roleSelect.value = emp.role || 'cashier';
var branchSelect = document.getElementById('new-emp-branch');
if (branchSelect) branchSelect.value = emp.branch_id || '';
setVal('new-emp-discount', emp.max_discount_pct || '');
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
// Store the ID so saveEmployee knows it's an update
var modal = document.getElementById('employee-modal');
if (modal) {
modal.dataset.editId = empId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Editar Empleado';
}
openModal('employee-modal');
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
async function saveTaxParams() {
if (!checkAuth()) return;
var data = {
tax_iva: getVal('tax-iva') || '16',
tax_ieps: getVal('tax-ieps') || '0',
invoice_serie: getVal('tax-serie') || 'FA',
invoice_folio: getVal('tax-folio') || '1',
default_currency: document.getElementById('tax-moneda') ? document.getElementById('tax-moneda').value : 'MXN',
default_payment_method: document.getElementById('tax-forma-pago') ? document.getElementById('tax-forma-pago').value : '01',
};
try {
// Use the business PUT endpoint with tax_ prefixed keys
var res = await fetch(API + '/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Error al guardar');
toast('Parámetros de impuestos guardados', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
async function saveBusiness() {
if (!checkAuth()) return;
var data = {
razon_social: getVal('biz-razon-social'),
nombre: getVal('biz-nombre'),
rfc: getVal('biz-rfc'),
regimen_fiscal: getVal('biz-regimen'),
direccion: getVal('biz-direccion'),
telefono: getVal('biz-telefono'),
email: getVal('biz-email'),
};
try {
var res = await fetch(API + '/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Error al guardar');
}
toast('Datos de empresa guardados', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
// -------------------------------------------------------------------------
// Event bindings
// -------------------------------------------------------------------------
@@ -525,7 +627,8 @@ const Config = (() => {
return {
init, setTheme, selectThemeOption,
loadBranches, loadEmployees, saveBranch, saveEmployee,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
openModal, closeModal
};

404
pos/static/js/pos-utils.js Normal file
View File

@@ -0,0 +1,404 @@
/**
* pos-utils.js — Shared utility functions for all POS pages.
*
* Provides common operations that multiple pages need:
* - CSV export of any visible table
* - Print page (PDF via browser print dialog)
* - Toast notifications (if page doesn't have its own)
*
* Load this script in every POS template BEFORE page-specific JS.
*/
(function() {
'use strict';
// ── CSV Export ──────────────────────────────────────────────────
// Finds the first visible <table> on the page and downloads it as CSV.
// Works on inventory, customers, invoicing, reports, accounting.
window.exportVisibleTableCSV = function(prefix) {
prefix = prefix || 'datos';
var tables = document.querySelectorAll('table');
var table = null;
// Find first visible table with data rows
for (var i = 0; i < tables.length; i++) {
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
table = tables[i];
break;
}
}
if (!table) {
showToast('No hay tabla de datos para exportar en esta vista.', 'warn');
return;
}
var rows = [];
// Header row
var ths = table.querySelectorAll('thead th');
if (ths.length) {
rows.push(Array.from(ths).map(function(th) {
return '"' + th.textContent.trim().replace(/"/g, '""') + '"';
}).join(','));
}
// Data rows
table.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
rows.push(Array.from(cells).map(function(td) {
return '"' + td.textContent.trim().replace(/"/g, '""') + '"';
}).join(','));
});
if (rows.length <= 1) {
showToast('La tabla está vacía — no hay datos para exportar.', 'warn');
return;
}
var csv = rows.join('\n');
// BOM prefix so Excel opens UTF-8 correctly
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = prefix + '_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('CSV descargado: ' + a.download, 'ok');
};
// ── Print (PDF) ────────────────────────────────────────────────
window.printPage = function() {
window.print();
};
// ── Toast (simple, non-blocking notification) ──────────────────
// Only creates its own toast if the page doesn't already have one.
window.showToast = function(msg, type) {
type = type || 'info';
var container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
document.body.appendChild(container);
}
var colors = {
ok: 'background:#1a7a3a;color:#fff;',
error: 'background:#c0392b;color:#fff;',
warn: 'background:#d4a017;color:#000;',
info: 'background:var(--color-surface-3,#333);color:var(--color-text-primary,#fff);',
};
var toast = document.createElement('div');
toast.style.cssText = (colors[type] || colors.info) +
'padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;' +
'box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;' +
'animation:slideInRight 0.3s ease;max-width:400px;';
toast.textContent = msg;
container.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
};
// ── "Próximamente" placeholder for features not yet built ──────
window.featureProximamente = function(nombre) {
showToast((nombre || 'Esta función') + ' estará disponible próximamente.', 'info');
};
// ── Table Filter Panel ────────────────────────────────────────
// Creates a dropdown filter panel that filters visible table rows
// client-side. Call toggleFilterPanel(buttonEl, config) where config
// is an array of {label, column, values} describing each filter.
//
// Usage (from onclick):
// toggleFilterPanel(this, [
// {label: 'Marca', column: 2, values: ['BOSCH','MONROE','Todas']},
// {label: 'Status', column: 4, values: ['Activo','Inactivo','Todos']},
// ])
var _activeFilterPanel = null;
window.toggleFilterPanel = function(btnEl, filters) {
// Close existing panel if open
if (_activeFilterPanel) {
_activeFilterPanel.remove();
_activeFilterPanel = null;
return;
}
var panel = document.createElement('div');
panel.className = 'filter-panel';
panel.style.cssText = 'position:absolute;top:100%;right:0;z-index:1000;' +
'background:var(--glass-bg-strong,#1a1a1a);backdrop-filter:blur(16px);' +
'border:1px solid var(--glass-border,#333);border-radius:var(--radius-lg,12px);' +
'padding:16px;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.3);' +
'display:flex;flex-direction:column;gap:12px;';
var title = document.createElement('div');
title.style.cssText = 'font-weight:700;font-size:14px;display:flex;justify-content:space-between;align-items:center;';
title.innerHTML = 'Filtros <button onclick="closeFilterPanel()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:18px;">✕</button>';
panel.appendChild(title);
filters.forEach(function(f) {
var group = document.createElement('div');
var label = document.createElement('label');
label.style.cssText = 'display:block;font-size:12px;color:var(--color-text-muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em;';
label.textContent = f.label;
group.appendChild(label);
var select = document.createElement('select');
select.style.cssText = 'width:100%;padding:8px 10px;background:var(--glass-bg,#222);' +
'border:1px solid var(--glass-border,#444);border-radius:6px;' +
'color:var(--color-text-primary,#fff);font-size:13px;';
select.dataset.filterColumn = f.column;
// "Todos" option always first
var allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = f.allLabel || 'Todos';
select.appendChild(allOpt);
(f.values || []).forEach(function(v) {
if (!v) return;
var opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
select.appendChild(opt);
});
select.addEventListener('change', function() { applyFilters(panel); });
group.appendChild(select);
panel.appendChild(group);
});
// Clear all button
var clearBtn = document.createElement('button');
clearBtn.style.cssText = 'padding:8px;background:transparent;border:1px dashed var(--glass-border,#444);' +
'border-radius:6px;color:var(--color-text-muted);cursor:pointer;font-size:12px;';
clearBtn.textContent = 'Limpiar filtros';
clearBtn.addEventListener('click', function() {
panel.querySelectorAll('select').forEach(function(s) { s.value = ''; });
applyFilters(panel);
});
panel.appendChild(clearBtn);
// Position relative to the button
var wrapper = btnEl.parentElement;
if (wrapper) wrapper.style.position = 'relative';
(wrapper || document.body).appendChild(panel);
_activeFilterPanel = panel;
// Close on outside click
setTimeout(function() {
document.addEventListener('click', function handler(e) {
if (!panel.contains(e.target) && e.target !== btnEl) {
closeFilterPanel();
document.removeEventListener('click', handler);
}
});
}, 100);
};
window.closeFilterPanel = function() {
if (_activeFilterPanel) {
_activeFilterPanel.remove();
_activeFilterPanel = null;
}
};
function applyFilters(panel) {
var selects = panel.querySelectorAll('select[data-filter-column]');
// Find the nearest visible table
var tables = document.querySelectorAll('table');
var table = null;
for (var i = 0; i < tables.length; i++) {
if (tables[i].offsetParent !== null) { table = tables[i]; break; }
}
if (!table) return;
var rows = table.querySelectorAll('tbody tr');
rows.forEach(function(tr) {
var show = true;
selects.forEach(function(sel) {
var col = parseInt(sel.dataset.filterColumn);
var val = sel.value.toLowerCase();
if (!val) return; // "Todos" — no filter
var cells = tr.querySelectorAll('td');
if (cells[col]) {
var cellText = cells[col].textContent.trim().toLowerCase();
if (cellText.indexOf(val.toLowerCase()) === -1) show = false;
}
});
tr.style.display = show ? '' : 'none';
});
// Update count badge if exists
var visibleCount = 0;
rows.forEach(function(tr) { if (tr.style.display !== 'none') visibleCount++; });
var badge = document.querySelector('.filter-count-badge');
if (badge) badge.textContent = visibleCount + ' resultados';
}
// ── Auto-extract unique values from a table column ──────────
// Useful for building filter options dynamically from data.
window.getUniqueColumnValues = function(tableEl, colIndex, maxValues) {
maxValues = maxValues || 30;
var values = {};
if (!tableEl) return [];
tableEl.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
if (cells[colIndex]) {
var v = cells[colIndex].textContent.trim();
if (v && v !== '-' && v !== '') values[v] = (values[v] || 0) + 1;
}
});
// Sort by frequency (most common first)
return Object.keys(values)
.sort(function(a, b) { return values[b] - values[a]; })
.slice(0, maxValues);
};
// ── Auto-print polling for WhatsApp quotations ───────────────
// Polls /quotations/print-queue every 15s. When a confirmed WA quote
// is found, it fetches the ESC/POS bytes and sends to the connected
// thermal printer. Falls back to browser print if no thermal is connected.
var _autoPrintTimer = null;
var _autoPrintEnabled = false;
window.startAutoPrint = function() {
if (_autoPrintTimer) return;
_autoPrintEnabled = true;
var token = localStorage.getItem('pos_token');
if (!token) return;
_autoPrintTimer = setInterval(function() {
fetch('/pos/api/quotations/print-queue', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.data || !d.data.length) return;
d.data.forEach(function(q) {
console.log('[auto-print] Cotización #' + q.id + ' confirmada por WhatsApp — imprimiendo...');
showToast('🖨️ Imprimiendo cotización #' + q.id + ' (WhatsApp)', 'ok');
autoPrintQuote(q.id, token);
});
})
.catch(function() {}); // silent on errors
}, 15000); // every 15 seconds
console.log('[auto-print] Enabled — polling every 15s');
};
window.stopAutoPrint = function() {
if (_autoPrintTimer) {
clearInterval(_autoPrintTimer);
_autoPrintTimer = null;
}
_autoPrintEnabled = false;
};
function autoPrintQuote(quoteId, token) {
// Try thermal printer first (via NexusPrinter if loaded)
if (typeof NexusPrinter !== 'undefined' && NexusPrinter.isConnected && NexusPrinter.isConnected()) {
fetch('/pos/api/quotations/' + quoteId + '/print', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ printer_type: 'escpos_raw', width: 80 }),
})
.then(function(r) { return r.arrayBuffer(); })
.then(function(buf) {
NexusPrinter.sendRaw(new Uint8Array(buf));
markPrinted(quoteId, token);
})
.catch(function(e) {
console.error('[auto-print] Thermal print failed:', e);
browserPrintQuote(quoteId, token);
});
} else {
browserPrintQuote(quoteId, token);
}
}
function browserPrintQuote(quoteId, token) {
// Fallback: open a print-friendly window
fetch('/pos/api/quotations/' + quoteId + '/print', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ printer_type: 'browser' }),
})
.then(function(r) { return r.json(); })
.then(function(q) {
var html = '<html><head><title>Cotización #' + q.id + '</title>';
html += '<style>body{font-family:monospace;font-size:12px;width:80mm;margin:0 auto;padding:10px;}';
html += 'h1{font-size:18px;text-align:center;margin:0;}';
html += '.center{text-align:center;}.right{text-align:right;}';
html += 'hr{border:none;border-top:1px dashed #000;}';
html += 'table{width:100%;border-collapse:collapse;}td{padding:2px 4px;}</style></head><body>';
html += '<h1>COTIZACIÓN</h1>';
html += '<p class="center">COT-' + q.id + '</p>';
html += '<p>Fecha: ' + (q.created_at || '').substring(0, 10) + '</p>';
if (q.customer_name) html += '<p>Cliente: ' + q.customer_name + '</p>';
if (q.wa_phone) html += '<p>WhatsApp: ' + q.wa_phone + '</p>';
html += '<hr><table>';
(q.items || []).forEach(function(it) {
html += '<tr><td>' + it.quantity + 'x ' + it.name + '</td><td class="right">$' + it.subtotal.toFixed(2) + '</td></tr>';
if (it.part_number) html += '<tr><td colspan="2" style="font-size:10px;color:#666;"> #' + it.part_number + '</td></tr>';
});
html += '</table><hr>';
html += '<p class="right">Subtotal: $' + q.subtotal.toFixed(2) + '</p>';
html += '<p class="right">IVA: $' + q.tax_total.toFixed(2) + '</p>';
html += '<p class="right" style="font-size:16px;font-weight:bold;">TOTAL: $' + q.total.toFixed(2) + '</p>';
html += '<hr><p class="center" style="font-size:10px;">Esta cotización no es comprobante fiscal<br>Precios sujetos a disponibilidad</p>';
html += '</body></html>';
var w = window.open('', '_blank', 'width=400,height=600');
w.document.write(html);
w.document.close();
setTimeout(function() { w.print(); }, 500);
markPrinted(quoteId, token);
})
.catch(function(e) {
console.error('[auto-print] Browser print failed:', e);
});
}
function markPrinted(quoteId, token) {
fetch('/pos/api/quotations/' + quoteId + '/mark-printed', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
}).catch(function() {});
}
// Auto-start polling on pages that are likely to have a printer
// (POS sale page and quotations page)
if (window.location.pathname.indexOf('/pos/sale') !== -1 ||
window.location.pathname.indexOf('/pos/quotation') !== -1 ||
window.location.pathname.indexOf('/pos/dashboard') !== -1) {
var _initToken = localStorage.getItem('pos_token');
if (_initToken) {
setTimeout(function() { startAutoPrint(); }, 3000);
}
}
// Inject styles
if (!document.getElementById('pos-utils-styles')) {
var style = document.createElement('style');
style.id = 'pos-utils-styles';
style.textContent = '@keyframes slideInRight{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}' +
'.filter-panel select:focus{outline:none;border-color:var(--color-primary,#F5A623);box-shadow:0 0 0 2px var(--glow-color-soft,rgba(245,166,35,0.15));}';
document.head.appendChild(style);
}
})();

View File

@@ -715,3 +715,39 @@ const Reports = (() => {
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
};
})();
// ── Global: Export visible table as CSV (Excel-compatible) ──
function exportReportCSV() {
var tables = document.querySelectorAll('table');
// Find the first visible table
var table = null;
for (var i = 0; i < tables.length; i++) {
var t = tables[i];
if (t.offsetParent !== null && t.querySelector('tbody tr')) {
table = t;
break;
}
}
if (!table) {
alert('No hay tabla de datos para exportar en esta vista.');
return;
}
var rows = [];
var ths = table.querySelectorAll('thead th');
if (ths.length) {
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
}
table.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
});
if (rows.length <= 1) { alert('La tabla esta vacia.'); return; }
var csv = rows.join('\n');
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'reporte_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -27,6 +27,8 @@
]},
{ label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
@@ -163,4 +165,61 @@
var main = document.querySelector('main, .main-content, #mainContent, .main, .page-content');
if (main) main.classList.add('pos-main-offset');
// ── Tablet/mobile: sidebar toggle + overlay ─────────────────────
// Creates a hamburger button + overlay for screens < 1024px.
// The CSS in pos-glass.css hides the sidebar by default on tablets
// and shows it as a slide-in drawer when .open is added.
var sidebar = document.querySelector('.pos-sidebar, .sidebar, #sidebar');
var overlay = document.getElementById('sidebar-overlay');
// Create overlay if it doesn't exist
if (!overlay && sidebar) {
overlay = document.createElement('div');
overlay.id = 'sidebar-overlay';
overlay.className = 'sidebar-overlay';
overlay.addEventListener('click', function () { closeSidebar(); });
sidebar.parentNode.insertBefore(overlay, sidebar);
}
// Create hamburger button if it doesn't exist
var hamburger = document.getElementById('hamburger-btn');
if (!hamburger) {
hamburger = document.createElement('button');
hamburger.id = 'hamburger-btn';
hamburger.className = 'hamburger-btn';
hamburger.setAttribute('aria-label', 'Menú');
hamburger.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
hamburger.style.cssText = 'display:none;position:fixed;top:10px;left:10px;z-index:' +
(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-modal') || 1050) + 2) +
';background:var(--glass-bg-strong);backdrop-filter:blur(12px);border:1px solid var(--glass-border);' +
'border-radius:var(--radius-md);padding:8px;cursor:pointer;color:var(--color-text-primary);' +
'box-shadow:0 2px 8px rgba(0,0,0,0.2);';
hamburger.addEventListener('click', function () { toggleSidebar(); });
document.body.appendChild(hamburger);
}
function toggleSidebar() {
if (!sidebar) return;
var isOpen = sidebar.classList.contains('open');
sidebar.classList.toggle('open', !isOpen);
if (overlay) overlay.classList.toggle('open', !isOpen);
document.body.style.overflow = isOpen ? '' : 'hidden';
}
function closeSidebar() {
if (sidebar) sidebar.classList.remove('open');
if (overlay) overlay.classList.remove('open');
document.body.style.overflow = '';
}
// Auto-close sidebar on window resize to desktop
window.addEventListener('resize', function () {
if (window.innerWidth >= 1024) closeSidebar();
});
// Expose globally so inline onclick handlers and page-specific JS can call them
window.toggleSidebar = toggleSidebar;
window.closeSidebar = closeSidebar;
})();

View File

@@ -103,6 +103,9 @@
messengerArea.style.display = 'flex';
disconnectBtn.style.display = '';
connectBtn.style.display = 'none';
// Load conversations + start polling on page load / reconnect
loadConversations();
startPolling();
} else if (state === 'connecting') {
statusDot.className = 'status-dot status-dot--warn';
statusText.textContent = 'Escaneando QR...';
@@ -221,18 +224,43 @@
var html = '';
convs.forEach(function (c) {
var isActive = c.phone === activePhone;
var dirIcon = c.last_direction === 'outgoing' ? '&rarr; ' : '';
var dirIcon = c.last_direction === 'outgoing' ? ' ' : '';
// Show contact name if available, otherwise try to format the phone.
// LID numbers (15+ digits, no country code pattern) show as "Contacto"
var displayName = c.contact_name || '';
if (!displayName) {
var isLid = c.phone.length > 13 || !/^(52|1|44|34)/.test(c.phone);
displayName = isLid ? 'Contacto WhatsApp' : fmtPhone(c.phone);
}
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
+ '<div class="conv-item__phone">' + escHtml(fmtPhone(c.phone)) + '</div>'
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message) + '</div>'
+ '<div class="conv-item__phone">' + escHtml(displayName) + '</div>'
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message || '(sin texto)') + '</div>'
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
+ '<button class="conv-item__delete" data-del-phone="' + escHtml(c.phone) + '" title="Borrar conversacion">&times;</button>'
+ '</div>';
});
// "Borrar todo" button at the bottom
html += '<div style="padding:8px;text-align:center;">'
+ '<button class="conv-delete-all" style="background:none;border:1px dashed var(--color-border,#444);color:var(--color-text-muted);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;" onclick="deleteAllConversations()">Borrar todas las conversaciones</button>'
+ '</div>';
convList.innerHTML = html;
convList.querySelectorAll('.conv-item').forEach(function (el) {
el.addEventListener('click', function () {
openConversation(el.getAttribute('data-phone'));
el.addEventListener('click', function (e) {
if (e.target.classList.contains('conv-item__delete')) return;
var name = el.querySelector('.conv-item__phone') ? el.querySelector('.conv-item__phone').textContent : '';
openConversation(el.getAttribute('data-phone'), name);
});
});
// Wire delete buttons
convList.querySelectorAll('.conv-item__delete').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var phone = btn.getAttribute('data-del-phone');
if (confirm('Borrar conversacion con ' + fmtPhone(phone) + '?')) {
deleteConversation(phone);
}
});
});
}).catch(function () {
@@ -240,11 +268,43 @@
});
}
function deleteConversation(phone) {
api('DELETE', '/conversations/' + encodeURIComponent(phone)).then(function (res) {
if (res.ok) {
if (activePhone === phone) {
activePhone = null;
chatPanel.style.display = 'none';
emptyState.style.display = '';
}
loadConversations();
} else {
alert('Error: ' + (res.error || 'unknown'));
}
});
}
window.deleteAllConversations = function () {
if (!confirm('Borrar TODAS las conversaciones? Esta accion no se puede deshacer.')) return;
api('DELETE', '/conversations').then(function (res) {
if (res.ok) {
activePhone = null;
chatPanel.style.display = 'none';
emptyState.style.display = '';
loadConversations();
}
});
};
// -- Open a conversation ---------------------------------------------------
function openConversation(phone) {
var activeContactName = '';
function openConversation(phone, contactName) {
activePhone = phone;
chatHeader.textContent = fmtPhone(phone);
// Use contact name if available; fall back to formatted phone
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
activeContactName = contactName || '';
chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone));
emptyState.style.display = 'none';
chatPanel.style.display = 'flex';
@@ -267,13 +327,13 @@
var html = '';
msgs.forEach(function (m) {
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
var statusBadge = '';
if (m.direction === 'outgoing' && m.status) {
statusBadge = '<span class="msg-status">' + escHtml(m.status) + '</span>';
}
// Support both 'text' and 'message_text' keys (backend changed)
var text = m.message_text || m.text || '';
// Support both 'created_at' and 'date' keys
var time = m.created_at || m.date || '';
html += '<div class="msg-bubble ' + cls + '">'
+ '<div class="msg-bubble__text">' + escHtml(m.message_text).replace(/\n/g, '<br>') + '</div>'
+ '<div class="msg-bubble__meta">' + fmtTime(m.created_at) + ' ' + statusBadge + '</div>'
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
+ '</div>';
});
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
@@ -328,16 +388,50 @@
if (quoteBtn) {
quoteBtn.addEventListener('click', function () {
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
var quoteId = prompt('ID de la cotizacion a enviar:');
if (!quoteId) return;
api('POST', '/send-quote/' + quoteId, { phone: activePhone }).then(function (res) {
if (res.error) {
alert('Error: ' + res.error);
} else {
loadMessages(activePhone);
loadConversations();
}
});
// Fetch available quotations and let user pick one
fetch('/pos/api/quotations?per_page=20', { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
var quotes = (d.data || []).filter(function (q) { return q.status === 'active'; });
if (quotes.length === 0) {
alert('No hay cotizaciones activas. Crea una desde el POS (F4) o via WhatsApp.');
return;
}
var msg = 'Cotizaciones activas:\n';
quotes.forEach(function (q) {
msg += '#' + q.id + ' — $' + q.total.toFixed(2) + ' (' + (q.customer_name || q.source || 'sin cliente') + ')\n';
});
var quoteId = prompt(msg + '\nEscribe el ID de la cotizacion a enviar:');
if (!quoteId) return;
// Fetch the quotation detail and send it formatted
fetch('/pos/api/quotations/' + quoteId, { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (q) {
if (q.error) { alert('Error: ' + q.error); return; }
// Format the quotation as a WhatsApp message
var lines = ['📄 *COTIZACIÓN #' + q.id + '*', ''];
(q.items || []).forEach(function (it, i) {
lines.push((i + 1) + '. ' + it.name);
lines.push(' #' + it.part_number + ' × ' + it.quantity + ' = $' + it.subtotal.toFixed(2));
});
lines.push('─────────────');
lines.push('Subtotal: $' + q.subtotal.toFixed(2));
lines.push('IVA: $' + q.tax_total.toFixed(2));
lines.push('*TOTAL: $' + q.total.toFixed(2) + '*');
var text = lines.join('\n');
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
if (res.error) {
alert('Error enviando: ' + res.error);
} else {
loadMessages(activePhone);
loadConversations();
}
});
});
});
});
}