Files
Autoparts-DB/dashboard/dashboard.min.js
consultoria-as 21959f1b37 FASE 7d: Lazy Loading + Minificación + Auto-serve minified
Cambios implementados:

1. Lazy loading de imágenes:
   - catalog.js: loading="lazy" decoding="async" en part cards y detail panel
   - inventory.js: lazy loading en imagen de detalle de item

2. Minificación de assets:
   - scripts/minify-assets.sh: minifica JS (terser) y CSS para POS y Dashboard
   - 25 archivos .min.js + 5 .min.css generados en pos/static/
   - 14 archivos .min.js + 8 .min.css generados en dashboard/

3. Nginx auto-serve minified:
   - try_files $1.min.js antes de servir .js original
   - try_files $1.min.css antes de servir .css original
   - Transparente para los templates HTML (cero cambios en HTML)

4. Cache warming script:
   - scripts/warm_vehicle_cache.py: pobla Redis con vehicle info por batches
   - Mitiga DISTINCT ON + 4 JOINs sobre 2B filas
   - Corre en background, procesa ~1.5M parts

Tests: 73/73 pasando
2026-04-27 08:34:24 +00:00

1 line
65 KiB
JavaScript

class VehicleDashboard{constructor(){this.currentView="brands",this.selectedBrand=null,this.selectedModel=null,this.selectedYear=null,this.selectedVehicleId=null,this.selectedCategory=null,this.selectedGroupId=null,this.selectedGroup=null,this.allVehicles=[],this.filteredVehicles=[],this.allCategories=[],this.allParts=[],this.stats={brands:0,models:0,vehicles:0,parts:0},this.currentDiagramZoom=1,this.lastFocusedElement=null,this.init()}async init(){await this.loadStats(),await this.showBrands(),this.bindFilterEvents(),this.bindKeyboardShortcuts(),this.bindSearchEvents(),this.initDarkMode()}bindSearchEvents(){const e=document.getElementById("searchInput");e&&e.addEventListener("keypress",(e=>{"Enter"===e.key&&this.searchPartNumber()}))}async loadStats(){try{const[e,t,a]=await Promise.all([fetch("/api/catalog/stats"),fetch("/api/brands"),fetch("/api/categories")]);if(e.ok){const t=await e.json();this.stats.brands=t.brands,this.stats.models=t.models,this.stats.vehicles=t.vehicles,this.stats.parts=t.parts;const a=e=>e>1e3?Math.floor(e/1e3)+"K+":e,s=document.getElementById("totalBrands"),n=document.getElementById("totalModels"),i=document.getElementById("totalParts");s&&(s.textContent=a(this.stats.brands)),n&&(n.textContent=a(this.stats.models)),i&&(i.textContent=a(this.stats.parts))}t.ok&&await t.json(),a.ok&&(this.allCategories=await a.json())}catch(e){console.error("Error loading stats:",e)}}updateBreadcrumb(){const e=document.getElementById("breadcrumb");let t=[];if("brands"===this.currentView)t.push({label:'<i class="fas fa-home"></i> Marcas',active:!0});else if("models"===this.currentView)t.push({label:'<i class="fas fa-home"></i> Marcas',action:"dashboard.goToBrands()"}),t.push({label:this.selectedBrand,active:!0});else if("vehicles"===this.currentView)t.push({label:'<i class="fas fa-home"></i> Marcas',action:"dashboard.goToBrands()"}),t.push({label:this.selectedBrand,action:`dashboard.goToModels('${this.selectedBrand.replace(/'/g,"\\'")}')`}),t.push({label:this.selectedModel,active:!0});else if("categories"===this.currentView)t.push({label:'<i class="fas fa-home"></i> Marcas',action:"dashboard.goToBrands()"}),t.push({label:this.selectedBrand,action:`dashboard.goToModels('${this.selectedBrand.replace(/'/g,"\\'")}')`}),t.push({label:this.selectedModel,action:`dashboard.goToVehicles('${this.selectedBrand.replace(/'/g,"\\'")}', '${this.selectedModel.replace(/'/g,"\\'")}')`}),this.selectedYear&&t.push({label:this.selectedYear}),t.push({label:"Categorías",active:!0});else if("groups"===this.currentView)t.push({label:'<i class="fas fa-home"></i> Marcas',action:"dashboard.goToBrands()"}),t.push({label:this.selectedBrand,action:`dashboard.goToModels('${this.selectedBrand.replace(/'/g,"\\'")}')`}),t.push({label:this.selectedModel,action:`dashboard.goToVehicles('${this.selectedBrand.replace(/'/g,"\\'")}', '${this.selectedModel.replace(/'/g,"\\'")}')`}),this.selectedYear&&t.push({label:this.selectedYear}),t.push({label:"Categorías",action:`dashboard.goToCategories(${this.selectedVehicleId})`}),t.push({label:this.selectedCategory?this.selectedCategory.name_es||this.selectedCategory.name:"Grupos",active:!0});else if("parts"===this.currentView){const e=this.selectedGroup?this.selectedGroup.name_es||this.selectedGroup.name:"Grupo";t.push({label:'<i class="fas fa-home"></i> Marcas',action:"dashboard.goToBrands()"}),t.push({label:this.selectedBrand,action:`dashboard.goToModels('${this.selectedBrand.replace(/'/g,"\\'")}')`}),t.push({label:this.selectedModel,action:`dashboard.goToVehicles('${this.selectedBrand.replace(/'/g,"\\'")}', '${this.selectedModel.replace(/'/g,"\\'")}')`}),this.selectedYear&&t.push({label:this.selectedYear}),t.push({label:"Categorías",action:`dashboard.goToCategories(${this.selectedVehicleId})`}),t.push({label:this.selectedCategory?this.selectedCategory.name_es||this.selectedCategory.name:"Categoría",action:`dashboard.goToGroups(${this.selectedCategory?this.selectedCategory.id:0})`}),t.push({label:e,active:!0})}e.innerHTML=t.map(((e,a)=>{const s=a===t.length-1?"":'<span class="breadcrumb-separator">/</span>';return e.action?`<span class="breadcrumb-item"><a href="#" onclick="${e.action}; return false;">${e.label}</a></span>${s}`:e.active?`<span class="breadcrumb-item active">${e.label}</span>`:`<span class="breadcrumb-item">${e.label}</span>${s}`})).join("")}bindKeyboardShortcuts(){document.addEventListener("keydown",(e=>{const t="INPUT"===e.target.tagName||"TEXTAREA"===e.target.tagName;if("Escape"!==e.key){if(!t){if(!("/"===e.key||e.ctrlKey&&"k"===e.key))return e.ctrlKey&&"d"===e.key?(e.preventDefault(),void this.toggleDarkMode()):"Backspace"===e.key?(e.preventDefault(),void this.goBack()):void 0;{e.preventDefault();const t=document.getElementById("searchInput");t&&t.focus()}}}else this.closeAllModals()}))}closeAllModals(){["partDetailModal","searchResultsModal","diagramModal","vinDecoderModal"].forEach((e=>{this.closeModal(e)}))}openModal(e){this.lastFocusedElement=document.activeElement;const t=document.getElementById(e);t&&(t.classList.add("active"),setTimeout((()=>{const e=t.querySelector('input, button, [tabindex]:not([tabindex="-1"])');e&&e.focus()}),100))}closeModal(e){const t=document.getElementById(e);t&&(t.classList.remove("active"),this.lastFocusedElement&&this.lastFocusedElement.focus())}goBack(){switch(this.currentView){case"models":this.goToBrands();break;case"vehicles":this.goToModels(this.selectedBrand);break;case"categories":this.goToVehicles(this.selectedBrand,this.selectedModel);break;case"groups":this.goToCategories(this.selectedVehicleId);break;case"parts":this.selectedCategory?this.goToGroups(this.selectedCategory.id):this.goToCategories(this.selectedVehicleId)}}initDarkMode(){}toggleDarkMode(){}updateDarkModeIcon(){}makeCardsAccessible(e,t){const a=document.querySelector(e);if(!a)return;a.querySelectorAll(t).forEach(((e,t)=>{e.setAttribute("tabindex","0"),e.setAttribute("role","button");const a=e.textContent.trim().replace(/\s+/g," ");e.setAttribute("aria-label",a.substring(0,100)),e.addEventListener("keydown",(t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),e.click())}))}))}openModalWithFocus(e){return this.openModal(e),{hide:()=>this.closeModal(e)}}async showBrands(){this.currentView="brands",this.selectedBrand=null,this.selectedModel=null,this.selectedYear=null,this.selectedGroup=null,this.updateBreadcrumb(),this.hideFilters();const e=document.getElementById("mainContent");e.innerHTML='\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando marcas...</h4>\n </div>\n ';try{const t=await fetch("/api/brands?detailed=true");if(!t.ok)throw new Error("Error al cargar datos");const a=await t.json(),s={};if(a.forEach((e=>{s[e.name]={models:{size:e.model_count},vehicles:e.vehicle_count}})),0===a.length)return void(e.innerHTML='\n <div class="empty-state">\n <i class="fas fa-car"></i>\n <h4>No hay marcas disponibles</h4>\n <p>Agrega algunas marcas a la base de datos</p>\n </div>\n ');e.innerHTML=`<div class="content-grid brands-grid">\n ${a.map((e=>`\n <div class="brand-card" onclick="dashboard.goToModels('${e.name}')">\n <div class="brand-icon">\n <i class="fas fa-car"></i>\n </div>\n <div class="brand-name">${e.name}</div>\n <div class="brand-count">\n ${e.model_count} modelos\n </div>\n <div class="brand-count">\n ${e.vehicle_count} vehículos\n </div>\n </div>\n `)).join("")}\n </div>`,this.makeCardsAccessible("#mainContent",".brand-card")}catch(t){console.error("Error:",t),e.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar marcas</h4>\n <p>${t.message}</p>\n </div>\n `}}async goToModels(e){this.currentView="models",this.selectedBrand=e,this.selectedModel=null,this.selectedYear=null,this.selectedGroup=null,this.updateBreadcrumb(),this.hideFilters();const t=document.getElementById("mainContent");t.innerHTML=`\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando modelos de ${e}...</h4>\n </div>\n `;try{const a=await fetch(`/api/models?brand=${encodeURIComponent(e)}&detailed=true`);if(!a.ok)throw new Error("Error al cargar datos");const s=await a.json();if(0===s.length)return void(t.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-car-side"></i>\n <h4>No hay modelos para ${e}</h4>\n <p>Esta marca no tiene modelos registrados</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToBrands()">\n <i class="fas fa-arrow-left"></i> Volver a marcas\n </button>\n </div>\n `);t.innerHTML=`<div class="content-grid models-grid">\n ${s.map((t=>{const a=t.year_count>1?`${t.year_min} - ${t.year_max}`:`${t.year_min}`;return`\n <div class="model-card" onclick="dashboard.goToVehicles('${e}', '${t.name}')">\n <div class="model-name">${t.name}</div>\n <div class="model-info">\n <i class="fas fa-calendar-alt"></i> ${a}\n </div>\n <div class="model-info">\n <i class="fas fa-cogs"></i> ${t.engine_count} motores\n </div>\n <div class="model-info">\n <i class="fas fa-list"></i> ${t.vehicle_count} variantes\n </div>\n </div>\n `})).join("")}\n </div>`,this.makeCardsAccessible("#mainContent",".model-card")}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar modelos</h4>\n <p>${e.message}</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToBrands()">\n <i class="fas fa-arrow-left"></i> Volver a marcas\n </button>\n </div>\n `}}async goToVehicles(e,t){this.currentView="vehicles",this.selectedBrand=e,this.selectedModel=t,this.selectedYear=null,this.selectedGroup=null,this.updateBreadcrumb(),this.showFilters();const a=document.getElementById("mainContent");a.innerHTML='\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando vehículos...</h4>\n </div>\n ';try{const[a,s]=await Promise.all([fetch(`/api/vehicles?brand=${encodeURIComponent(e)}&model=${encodeURIComponent(t)}&per_page=100`),fetch(`/api/model-year-engine?brand=${encodeURIComponent(e)}&model=${encodeURIComponent(t)}&per_page=100`)]);if(!a.ok||!s.ok)throw new Error("Error al cargar vehículos");const n=await a.json(),i=await s.json(),r=n.data||n,o=i.data||i;this.allVehicles=r.map((e=>{const t=o.find((t=>t.brand===e.brand&&t.model===e.model&&t.year===e.year&&t.engine===e.engine));return{...e,mye_id:t?t.id:null}})).filter((e=>null!==e.mye_id)),this.filteredVehicles=[...this.allVehicles],await this.populateFilters(e,t),this.displayVehicles()}catch(t){console.error("Error:",t),a.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar vehículos</h4>\n <p>${t.message}</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToModels('${e}')">\n <i class="fas fa-arrow-left"></i> Volver a modelos\n </button>\n </div>\n `}}async populateFilters(e,t){try{const[a,s]=await Promise.all([fetch(`/api/years?brand=${encodeURIComponent(e)}&model=${encodeURIComponent(t)}`),fetch(`/api/engines?brand=${encodeURIComponent(e)}&model=${encodeURIComponent(t)}`)]);if(a.ok){const e=await a.json();document.getElementById("yearFilter").innerHTML='<option value="">Todos los años</option>'+e.map((e=>`<option value="${e}">${e}</option>`)).join("")}if(s.ok){const e=await s.json();document.getElementById("engineFilter").innerHTML='<option value="">Todos los motores</option>'+e.map((e=>`<option value="${e}">${e}</option>`)).join("")}}catch(e){console.error("Error populating filters:",e)}}bindFilterEvents(){document.getElementById("yearFilter").addEventListener("change",(()=>{this.applyFilters()})),document.getElementById("engineFilter").addEventListener("change",(()=>{this.applyFilters()}))}applyFilters(){const e=document.getElementById("yearFilter").value,t=document.getElementById("engineFilter").value;this.filteredVehicles=this.allVehicles.filter((a=>!(e&&a.year.toString()!==e||t&&a.engine!==t))),this.displayVehicles()}getEngineConfig(e){if(!e)return"N/A";const t=e.toUpperCase(),a=t.match(/V(\d+)/);if(a)return`V${a[1]}`;const s=t.match(/I(\d+)|INLINE[- ]?(\d+)/);if(s)return`I${s[1]||s[2]}`;const n=t.match(/H(\d+)|FLAT[- ]?(\d+)/);if(n)return`H${n[1]||n[2]}`;const i=t.match(/W(\d+)/);if(i)return`W${i[1]}`;const r=t.match(/(\d)[- ]?CYL/);return r?`${r[1]} Cil`:t.includes("ELECTRIC")?"EV":t.includes("ROTARY")?"Rotary":"N/A"}formatDisplacement(e){if(!e||0===e)return"N/A";return`${(e/1e3).toFixed(1)}L`}formatFuelType(e){return{gasoline:"Gasolina",diesel:"Diésel",electric:"Eléctrico",hybrid:"Híbrido",other:"Otro"}[e]||e||"N/A"}displayVehicles(){const e=document.getElementById("mainContent");document.getElementById("resultCount").textContent=`${this.filteredVehicles.length} resultado${1!==this.filteredVehicles.length?"s":""}`,0!==this.filteredVehicles.length?e.innerHTML=`<div class="content-grid vehicles-grid">\n ${this.filteredVehicles.map((e=>`\n <div class="vehicle-card">\n <div class="vehicle-header">\n <div class="vehicle-title">${e.year} ${e.brand} ${e.model}</div>\n <div class="vehicle-engine">${e.engine}</div>\n </div>\n <div class="vehicle-body">\n <div class="vehicle-specs">\n <div class="spec-item">\n <i class="fas fa-gas-pump"></i>\n <div class="spec-value">${this.formatFuelType(e.fuel_type)}</div>\n </div>\n <div class="spec-item">\n <i class="fas fa-bolt"></i>\n <div class="spec-value">${e.power_hp||"N/A"} HP</div>\n </div>\n <div class="spec-item">\n <i class="fas fa-sync-alt"></i>\n <div class="spec-value">${e.torque_nm||"N/A"} Nm</div>\n </div>\n <div class="spec-item">\n <i class="fas fa-tachometer-alt"></i>\n <div class="spec-value">${this.formatDisplacement(e.displacement_cc)}</div>\n </div>\n <div class="spec-item">\n <i class="fas fa-circle-notch"></i>\n <div class="spec-value">${e.cylinders||"N/A"} Cil</div>\n </div>\n <div class="spec-item">\n <i class="fas fa-cog"></i>\n <div class="spec-value">${this.getEngineConfig(e.engine)}</div>\n </div>\n </div>\n ${e.trim_level&&"unknown"!==e.trim_level?`\n <div style="text-align: center; margin-top: 0.75rem;">\n <span style="background: var(--accent); color: white; padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.8rem;">${e.trim_level}</span>\n </div>\n `:""}\n <button class="btn-parts" onclick="dashboard.goToCategories(${e.mye_id})">\n <i class="fas fa-cogs"></i> Ver Partes\n </button>\n </div>\n </div>\n `)).join("")}\n </div>`:e.innerHTML='\n <div class="state-container">\n <i class="fas fa-car"></i>\n <h4>No se encontraron vehículos</h4>\n <p>Intenta ajustar los filtros</p>\n </div>\n '}goToBrands(){this.showBrands()}showFilters(){document.getElementById("filtersBar").classList.add("visible"),document.getElementById("yearFilter").value="",document.getElementById("engineFilter").value=""}hideFilters(){document.getElementById("filtersBar").classList.remove("visible")}async navigateToVehicle(e,t,a,s){this.selectedBrand=t,this.selectedModel=a,this.selectedYear=s,this.allVehicles.find((t=>t.mye_id===e))||this.allVehicles.push({mye_id:e,brand:t,model:a,year:s}),await this.goToCategories(e)}async navigateToVehicleCategory(e,t,a,s,n){if(this.selectedBrand=t,this.selectedModel=a,this.selectedYear=s,this.allVehicles.find((t=>t.mye_id===e))||this.allVehicles.push({mye_id:e,brand:t,model:a,year:s}),this.selectedVehicleId=e,0===this.allCategories.length)try{const e=await fetch("/api/categories");e.ok&&(this.allCategories=await e.json())}catch(e){console.error("Error loading categories:",e)}await this.goToGroups(n)}async goToCategories(e){if(!e||"null"===e||"undefined"===e){return void(document.getElementById("mainContent").innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Vehículo sin partes disponibles</h4>\n <p>Este vehículo no tiene partes registradas en el catálogo.</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToModels('${this.selectedBrand}')">\n <i class="fas fa-arrow-left"></i> Volver a modelos\n </button>\n </div>\n `)}this.currentView="categories",this.selectedVehicleId=e,this.selectedCategory=null;const t=this.allVehicles.find((t=>t.mye_id===e));t&&(this.selectedYear=t.year),this.updateBreadcrumb(),this.hideFilters();const a=document.getElementById("mainContent");a.innerHTML='\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando categorías...</h4>\n </div>\n ';try{const t=await fetch(`/api/vehicles/${e}/categories`);if(!t.ok)throw new Error("Error al cargar categorías");this.allCategories=await t.json(),this.displayCategories()}catch(e){console.error("Error:",e),a.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar categorías</h4>\n <p>${e.message}</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToVehicles('${this.selectedBrand}', '${this.selectedModel}')">\n <i class="fas fa-arrow-left"></i> Volver a vehículos\n </button>\n </div>\n `}}displayCategories(){const e=document.getElementById("mainContent");0!==this.allCategories.length?(e.innerHTML=`<div class="content-grid categories-grid">\n ${this.allCategories.map((e=>{const t=e.icon_name||"fa-cog",a=e.name_es||e.name;return`\n <div class="category-card" onclick="dashboard.goToGroups(${e.id})">\n <div class="category-icon">\n <i class="fas ${t}"></i>\n </div>\n <div class="category-name">${a}</div>\n <div class="category-count">\n ${e.children?e.children.length+" subcategorías":""}\n </div>\n </div>\n `})).join("")}\n </div>\n <div class="mt-3">\n <button class="btn btn-back" onclick="dashboard.goToVehicles('${this.selectedBrand}', '${this.selectedModel}')">\n <i class="fas fa-arrow-left"></i> Volver a vehículos\n </button>\n </div>`,this.makeCardsAccessible("#mainContent",".category-card")):e.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-folder-open"></i>\n <h4>No hay categorías disponibles</h4>\n <p>Este vehículo no tiene partes registradas</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToVehicles('${this.selectedBrand}', '${this.selectedModel}')">\n <i class="fas fa-arrow-left"></i> Volver a vehículos\n </button>\n </div>\n `}async goToGroups(e){this.currentView="groups";const t=this.allCategories.find((t=>t.id===e));this.selectedCategory=t||{id:e,name:"Categoria"},this.selectedGroup=null,this.updateBreadcrumb(),this.hideFilters();const a=document.getElementById("mainContent");a.innerHTML='\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando grupos...</h4>\n </div>\n ';try{let t;t=this.selectedVehicleId?`/api/vehicles/${this.selectedVehicleId}/groups?category_id=${e}`:`/api/categories/${e}/groups`;const a=await fetch(t);if(!a.ok)throw new Error("Error al cargar grupos");const s=await a.json();let n=[];if(this.selectedVehicleId&&(10===e||11===e))try{const t=await fetch(`/api/vehicles/${this.selectedVehicleId}/diagrams/by-category?category_id=${e}`);if(t.ok){const e=await t.json();for(const t of e)n.push(...t.diagrams)}}catch(e){console.error("Error loading diagrams for strip:",e)}this.displayGroups(s,e,n)}catch(e){console.error("Error:",e),a.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar grupos</h4>\n <p>${e.message}</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToCategories(${this.selectedVehicleId})">\n <i class="fas fa-arrow-left"></i> Volver a categorías\n </button>\n </div>\n `}}displayGroups(e,t,a=[]){const s=document.getElementById("mainContent");if(0===e.length&&0===a.length)return void(s.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-folder-open"></i>\n <h4>No hay grupos en esta categoría</h4>\n <button class="btn btn-back mt-3" onclick="dashboard.goToCategories(${this.selectedVehicleId})">\n <i class="fas fa-arrow-left"></i> Volver a categorías\n </button>\n </div>\n `);let n="";a.length>0&&(this._currentDiagramList=a,n=`\n <div class="diagrams-strip">\n <div class="diagrams-strip-header">\n <h5><i class="fas fa-drafting-compass"></i> Diagramas MOOG para tu vehículo</h5>\n <span class="strip-badge">${a.length} diagrama${1!==a.length?"s":""}</span>\n </div>\n <div class="diagrams-strip-scroll">\n ${a.map(((e,t)=>{const a=(e.name||"")[0],s="F"===a?"Delantera":"S"===a?"Dirección":"R"===a?"Trasera":"",n=e.image_url||"/static/diagrams/moog/"+e.name+".jpg";return`\n <div class="strip-card" onclick="dashboard.openDiagramViewer(${e.id}, ${t})"\n title="${e.name_es||e.name}">\n <img class="strip-card-img" src="${n}" alt="${e.name}"\n loading="lazy"\n onerror="this.style.display='none';this.parentElement.querySelector('.strip-card-body').style.paddingTop='3rem'">\n <div class="strip-card-body">\n <div class="strip-card-title">${e.name}</div>\n <div class="strip-card-type">${s}</div>\n </div>\n </div>`})).join("")}\n </div>\n </div>`),s.innerHTML=`\n <h4 class="mb-3">${this.selectedCategory.name_es||this.selectedCategory.name}</h4>\n ${n}\n <div class="content-grid categories-grid">\n ${e.map((e=>`\n <div class="category-card">\n <div class="category-icon" style="background: linear-gradient(135deg, var(--secondary-color), #2980b9);" onclick="dashboard.goToParts(${e.id})">\n <i class="fas fa-layer-group"></i>\n </div>\n <div class="category-name" onclick="dashboard.goToParts(${e.id})">${e.name_es||e.name}</div>\n <div class="mt-2">\n <button class="btn btn-sm btn-primary me-1" onclick="dashboard.goToParts(${e.id})">\n <i class="fas fa-cogs"></i> Ver Partes\n </button>\n <button class="btn btn-sm btn-outline-secondary" onclick="dashboard.goToDiagrams(${e.id})" title="Ver Diagramas">\n <i class="fas fa-project-diagram"></i> Diagramas\n </button>\n </div>\n </div>\n `)).join("")}\n </div>\n <div class="mt-3">\n <button class="btn btn-back" onclick="dashboard.goToCategories(${this.selectedVehicleId})">\n <i class="fas fa-arrow-left"></i> Volver a categorías\n </button>\n </div>`,this.makeCardsAccessible("#mainContent",".category-card")}async goToParts(e){this.currentView="parts",this.selectedGroupId=e;try{const t=await fetch(`/api/categories/${this.selectedCategory?this.selectedCategory.id:0}/groups`);if(t.ok){const a=await t.json();this.selectedGroup=a.find((t=>t.id===e))||{id:e,name:"Grupo"}}}catch(t){console.error("Error fetching group details:",t),this.selectedGroup={id:e,name:"Grupo"}}this.updateBreadcrumb(),this.hideFilters();const t=document.getElementById("mainContent");t.innerHTML='\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando partes...</h4>\n </div>\n ';try{let t;t=this.selectedVehicleId?`/api/vehicles/${this.selectedVehicleId}/parts?group_id=${e}`:`/api/parts?group_id=${e}`;const a=await fetch(t);if(!a.ok)throw new Error("Error al cargar partes");const s=await a.json();this.allParts=Array.isArray(s)?s:s.data||s,this.displayParts()}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar partes</h4>\n <p>${e.message}</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToGroups(${this.selectedCategory.id})">\n <i class="fas fa-arrow-left"></i> Volver a grupos\n </button>\n </div>\n `}}displayParts(){const e=document.getElementById("mainContent");0!==this.allParts.length?e.innerHTML=`\n <div class="parts-table">\n <table>\n <thead>\n <tr>\n <th><i class="fas fa-barcode"></i> OEM #</th>\n <th><i class="fas fa-tag"></i> Nombre</th>\n <th><i class="fas fa-layer-group"></i> Grupo</th>\n <th><i class="fas fa-eye"></i> Acción</th>\n </tr>\n </thead>\n <tbody>\n ${this.allParts.map((e=>`\n <tr>\n <td><span class="part-oem-badge">${e.oem_part_number||"N/A"}</span></td>\n <td>${e.name_es||e.name||"Sin nombre"}</td>\n <td>${e.group_name||"N/A"}</td>\n <td>\n <button class="btn-view" onclick="dashboard.showPartDetail(${e.id})">\n <i class="fas fa-search"></i> Ver\n </button>\n </td>\n </tr>\n `)).join("")}\n </tbody>\n </table>\n </div>\n <div class="mt-3">\n <button class="btn btn-back" onclick="dashboard.goToGroups(${this.selectedCategory.id})">\n <i class="fas fa-arrow-left"></i> Volver a grupos\n </button>\n </div>\n `:e.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-box-open"></i>\n <h4>No hay partes disponibles</h4>\n <p>Este grupo no tiene partes registradas aún</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToGroups(${this.selectedCategory.id})">\n <i class="fas fa-arrow-left"></i> Volver a grupos\n </button>\n </div>\n `}async showPartDetail(e){const t=document.getElementById("partDetailContent");t.innerHTML='\n <div class="text-center py-4">\n <i class="fas fa-spinner fa-spin fa-2x"></i>\n <p class="mt-2">Cargando detalles...</p>\n </div>\n ';this.openModalWithFocus("partDetailModal");try{const[a,s,n]=await Promise.all([fetch(`/api/parts/${e}`),fetch(`/api/parts/${e}/alternatives`),fetch(`/api/parts/${e}/cross-references`)]);if(!a.ok)throw new Error("Error al cargar detalle de la parte");const i=await a.json(),r=s.ok?await s.json():[],o=n.ok?await n.json():[];t.innerHTML=`\n <div class="row">\n <div class="col-12">\n <h4 class="mb-3">${i.name_es||i.name||"Sin nombre"}</h4>\n </div>\n </div>\n ${i.image_url?`\n <div style="text-align:center;margin-bottom:1rem;">\n <img src="${i.image_url}" alt="${i.oem_part_number||""}" style="max-width:100%;max-height:300px;border-radius:8px;object-fit:contain;" />\n </div>\n `:""}\n <div class="part-detail-row">\n <span class="part-detail-label">Número OEM</span>\n <span class="part-detail-value"><span class="part-oem-badge">${i.oem_part_number||"N/A"}</span></span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label">Categoría</span>\n <span class="part-detail-value">${i.category_name_es||i.category_name||"N/A"}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label">Grupo</span>\n <span class="part-detail-value">${i.group_name_es||i.group_name||"N/A"}</span>\n </div>\n ${i.description||i.description_es?`\n <div class="part-detail-row">\n <span class="part-detail-label">Descripción</span>\n <span class="part-detail-value">${i.description_es||i.description}</span>\n </div>\n `:""}\n\n \x3c!-- FASE 2: Cross-Referencias Section --\x3e\n ${this.renderCrossReferences(o)}\n\n \x3c!-- FASE 2: Alternativas Aftermarket Section --\x3e\n ${this.renderAlternatives(r)}\n `}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="text-center py-4">\n <i class="fas fa-exclamation-triangle fa-2x text-warning"></i>\n <p class="mt-2">${e.message}</p>\n </div>\n `}}renderCrossReferences(e){if(!e||0===e.length)return"";return`\n <div class="crossref-section">\n <h5><i class="fas fa-exchange-alt"></i> Cross-Referencias</h5>\n <div>\n ${e.map((e=>`<span class="crossref-badge">${e.cross_reference_number||e.part_number||e}${e.brand?` (${e.brand})`:""}</span>`)).join("")}\n </div>\n </div>\n `}renderAlternatives(e){if(!e||0===e.length)return"";return`\n <div class="alternatives-section">\n <h5><i class="fas fa-clone"></i> Alternativas Aftermarket</h5>\n <table class="alternatives-table">\n <thead>\n <tr>\n <th>Marca</th>\n <th>Número de Parte</th>\n <th>Nombre</th>\n <th>Calidad</th>\n <th>Precio</th>\n </tr>\n </thead>\n <tbody>\n ${e.map((e=>`\n <tr>\n <td>${e.brand||"N/A"}</td>\n <td>${e.part_number||"N/A"}</td>\n <td>${e.name_es||e.name||"N/A"}</td>\n <td>${this.getQualityBadge(e.quality_tier)}</td>\n <td class="price-tag">${this.formatPrice(e.price)}</td>\n </tr>\n `)).join("")}\n </tbody>\n </table>\n </div>\n `}getQualityBadge(e){const t={economy:{class:"quality-economy",label:"Económico"},standard:{class:"quality-standard",label:"Estándar"},premium:{class:"quality-premium",label:"Premium"},oem:{class:"quality-oem",label:"OEM"}},a=t[e?.toLowerCase()]||t.standard;return`<span class="quality-badge ${a.class}">${a.label}</span>`}formatPrice(e){return null==e?"N/A":new Intl.NumberFormat("es-MX",{style:"currency",currency:"MXN"}).format(e)}async searchPartNumber(){const e=document.getElementById("searchInput").value.trim();if(!e)return;if(17===e.length&&/^[A-HJ-NPR-Z0-9]{17}$/i.test(e)&&confirm("El texto parece ser un VIN. ¿Deseas decodificarlo?"))return document.getElementById("vinInput").value=e.toUpperCase(),void this.openVinDecoder();const t=document.getElementById("searchResultsContent");t.innerHTML=`\n <div class="text-center py-4">\n <i class="fas fa-spinner fa-spin fa-2x"></i>\n <p class="mt-2">Buscando "${e}"...</p>\n </div>\n `;this.openModalWithFocus("searchResultsModal");try{let t;t=await fetch(`/api/search/part-number/${encodeURIComponent(e)}`);let a=[];if(t.ok&&(a=await t.json()),0===a.length){const t=await fetch(`/api/search?q=${encodeURIComponent(e)}`);if(t.ok){const e=await t.json();a=e.parts||e||[]}}this.showSearchResults(a,e)}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="text-center py-4">\n <i class="fas fa-exclamation-triangle fa-2x text-warning"></i>\n <p class="mt-2">Error al buscar: ${e.message}</p>\n </div>\n `}}showSearchResults(e,t){const a=document.getElementById("searchResultsContent");if(document.getElementById("searchResultsModalLabel").innerHTML=`<i class="fas fa-search"></i> Resultados para "${t}"`,!e||0===e.length)return void(a.innerHTML=`\n <div class="text-center py-4">\n <i class="fas fa-search fa-2x text-muted"></i>\n <p class="mt-2">No se encontraron resultados para "${t}"</p>\n </div>\n `);const s=e.map((e=>`\n <div class="search-result-item" onclick="dashboard.showPartDetailFromSearch(${e.id})">\n <div class="d-flex justify-content-between align-items-center">\n <div>\n <div class="search-result-part-number">\n <span class="part-oem-badge">${e.oem_part_number||e.part_number||"N/A"}</span>\n </div>\n <div class="search-result-name mt-1">${e.name_es||e.name||"Sin nombre"}</div>\n </div>\n <div class="text-end">\n ${e.quality_tier?this.getQualityBadge(e.quality_tier):""}\n ${e.brand?`<div class="text-muted small mt-1">${e.brand}</div>`:""}\n </div>\n </div>\n </div>\n `)).join("");a.innerHTML=`\n <p class="text-muted mb-3">${e.length} resultado${1!==e.length?"s":""} encontrado${1!==e.length?"s":""}</p>\n <div class="search-results-list">\n ${s}\n </div>\n `,this.makeCardsAccessible("#searchResultsContent",".search-result-item")}showPartDetailFromSearch(e){const t=document.getElementById("searchResultsModal");t&&t.classList.remove("active"),setTimeout((()=>{this.showPartDetail(e)}),300)}async goToDiagrams(e){const t=document.getElementById("mainContent");t.innerHTML='\n <div class="loading-state">\n <i class="fas fa-spinner fa-spin"></i>\n <h4>Cargando diagramas...</h4>\n </div>\n ';try{const t=await fetch(`/api/groups/${e}/diagrams`);if(!t.ok)throw new Error("Error al cargar diagramas");const a=await t.json();this.displayDiagramThumbnails(a,e)}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-exclamation-triangle"></i>\n <h4>Error al cargar diagramas</h4>\n <p>${e.message}</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToGroups(${this.selectedCategory?this.selectedCategory.id:0})">\n <i class="fas fa-arrow-left"></i> Volver a grupos\n </button>\n </div>\n `}}displayDiagramThumbnails(e,t){const a=document.getElementById("mainContent");e&&0!==e.length?(a.innerHTML=`\n <div class="diagram-viewer">\n <div class="diagram-header">\n <h5 class="mb-0"><i class="fas fa-project-diagram"></i> Diagramas del Grupo</h5>\n <span class="badge bg-light text-dark">${e.length} diagrama${1!==e.length?"s":""}</span>\n </div>\n <div class="diagram-thumbnails">\n ${e.map((e=>`\n <div class="diagram-thumbnail" onclick="dashboard.showDiagram(${e.id})">\n <div style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f8f9fa; border-radius: 4px;">\n ${e.thumbnail_url?`<img src="${e.thumbnail_url}" alt="${e.name_es||e.name}">`:'<i class="fas fa-project-diagram fa-3x text-muted"></i>'}\n </div>\n <div class="diagram-thumbnail-name">${e.name_es||e.name||"Diagrama"}</div>\n </div>\n `)).join("")}\n </div>\n </div>\n <div class="mt-3">\n <button class="btn btn-back" onclick="dashboard.goToGroups(${this.selectedCategory?this.selectedCategory.id:0})">\n <i class="fas fa-arrow-left"></i> Volver a grupos\n </button>\n </div>\n `,this.makeCardsAccessible("#mainContent",".diagram-thumbnail")):a.innerHTML=`\n <div class="empty-state">\n <i class="fas fa-project-diagram"></i>\n <h4>No hay diagramas disponibles</h4>\n <p>Este grupo no tiene diagramas registrados</p>\n <button class="btn btn-back mt-3" onclick="dashboard.goToGroups(${this.selectedCategory?this.selectedCategory.id:0})">\n <i class="fas fa-arrow-left"></i> Volver a grupos\n </button>\n </div>\n `}async showDiagram(e){const t=document.getElementById("diagramModalContent"),a=document.getElementById("diagramModalTitle");t.innerHTML='\n <div class="text-center py-5">\n <i class="fas fa-spinner fa-spin fa-2x"></i>\n <p class="mt-2">Cargando diagrama...</p>\n </div>\n ';this.openModalWithFocus("diagramModal");try{const t=await fetch(`/api/diagrams/${e}`);if(!t.ok)throw new Error("Error al cargar diagrama");const s=await t.json();a.innerHTML=`<i class="fas fa-project-diagram"></i> ${s.name_es||s.name||"Diagrama"}`,this.currentDiagramZoom=1,this.renderDiagramWithHotspots(s)}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="text-center py-5">\n <i class="fas fa-exclamation-triangle fa-2x text-warning"></i>\n <p class="mt-2">Error al cargar diagrama: ${e.message}</p>\n </div>\n `}}renderDiagramWithHotspots(e){const t=document.getElementById("diagramModalContent"),a=e.hotspots||[];t.innerHTML=`\n <div class="diagram-header">\n <span>${e.description_es||e.description||""}</span>\n <div class="zoom-controls">\n <button onclick="dashboard.zoomDiagram(-0.1)" title="Reducir">\n <i class="fas fa-minus"></i>\n </button>\n <button onclick="dashboard.zoomDiagram(0)" title="Restablecer">\n <i class="fas fa-redo"></i>\n </button>\n <button onclick="dashboard.zoomDiagram(0.1)" title="Ampliar">\n <i class="fas fa-plus"></i>\n </button>\n </div>\n </div>\n <div class="diagram-container" style="overflow: auto;">\n <div class="diagram-svg-wrapper" id="diagramWrapper" style="transform: scale(${this.currentDiagramZoom}); transform-origin: center center;">\n ${e.svg_content?e.svg_content:e.image_url?`<img src="${e.image_url}" alt="${e.name_es||e.name}" style="max-width: 100%;">`:'<div class="text-center text-muted py-5">\n <i class="fas fa-image fa-4x"></i>\n <p class="mt-3">No hay imagen de diagrama disponible</p>\n </div>'}\n ${a.map(((e,t)=>this.renderHotspot(e,t))).join("")}\n </div>\n </div>\n ${a.length>0?`\n <div class="p-3 border-top">\n <h6 class="mb-2"><i class="fas fa-info-circle"></i> Leyenda de Partes</h6>\n <div class="row">\n ${a.map(((e,t)=>`\n <div class="col-md-4 col-sm-6 mb-2">\n <span class="badge bg-primary me-1">${t+1}</span>\n <span class="small">${e.name_es||e.name||e.label||"Parte "+(t+1)}</span>\n </div>\n `)).join("")}\n </div>\n </div>\n `:""}\n `}renderHotspot(e,t){const a=e.x||e.position_x||0,s=e.y||e.position_y||0,n=e.width||30,i=e.height||30;return`\n <div class="hotspot"\n style="left: ${a}px; top: ${s}px; width: ${n}px; height: ${i}px;"\n onclick="dashboard.onHotspotClick(${JSON.stringify(e).replace(/"/g,"&quot;")})"\n title="${e.name_es||e.name||e.label||""}">\n <div class="hotspot-label" style="left: ${n+5}px; top: 0;">\n ${t+1}\n </div>\n <svg width="${n}" height="${i}" style="position: absolute; top: 0; left: 0;">\n <circle cx="${n/2}" cy="${i/2}" r="${Math.min(n,i)/2-2}"\n fill="rgba(231, 76, 60, 0.3)"\n stroke="var(--accent-color)"\n stroke-width="2"/>\n </svg>\n </div>\n `}onHotspotClick(e){if(e.part_id){const t=document.getElementById("diagramModal");t&&t.classList.remove("active"),setTimeout((()=>{this.showPartDetail(e.part_id)}),300)}else{const t=e.name_es||e.name||e.label||"Parte",a=e.description_es||e.description||"";alert(`${t}${a?"\n\n"+a:""}`)}}zoomDiagram(e){const t=document.getElementById("diagramWrapper");t&&(this.currentDiagramZoom=0===e?1:Math.max(.5,Math.min(2,this.currentDiagramZoom+e)),t.style.transform=`scale(${this.currentDiagramZoom})`)}openDiagramViewer(e,t){this._dvCurrentIndex="number"==typeof t?t:-1,this._dvDiagramList=this._currentDiagramList||[],this._dvZoom=1,this._dvDragging=!1;document.getElementById("diagramViewerOverlay").classList.add("active"),document.body.style.overflow="hidden",this._loadDiagramInViewer(e),this._bindDiagramViewerEvents()}closeDiagramViewer(){document.getElementById("diagramViewerOverlay").classList.remove("active"),document.body.style.overflow="",this._unbindDiagramViewerEvents()}async _loadDiagramInViewer(e){const t=document.getElementById("dvTitle"),a=document.getElementById("dvSubtitle"),s=document.getElementById("dvImgWrapper"),n=document.getElementById("dvImg"),i=document.getElementById("dvPartsList"),r=document.getElementById("dvPartsCount");i.innerHTML='<div style="text-align:center;padding:3rem;color:var(--text-secondary)"><i class="fas fa-spinner fa-spin" style="font-size:1.5rem"></i><p style="margin-top:0.5rem">Cargando...</p></div>',r.textContent="...";try{const[i,r]=await Promise.all([fetch(`/api/diagrams/${e}`),fetch(`/api/diagrams/${e}/parts${this.selectedVehicleId?"?mye_id="+this.selectedVehicleId:""}`)]),o=await i.json(),l=await r.json(),d=(o.name||"")[0],c="F"===d?"Suspensión Delantera":"S"===d?"Dirección":"R"===d?"Suspensión Trasera":o.group_name||"";t.textContent=o.name||"Diagrama",a.textContent=o.name_es||c;const m=o.image_url||(o.image_path?"/"+o.image_path:"");n.src=m,n.alt=o.name_es||o.name,this._dvZoom=1,s.style.transform="",s.classList.remove("zoomed"),document.getElementById("dvZoomLevel").textContent="100%",this._renderViewerHotspots(o.hotspots||[],s),this._renderViewerParts(l,o.hotspots||[]);const h=document.getElementById("dvPrevBtn"),p=document.getElementById("dvNextBtn");h.disabled=this._dvCurrentIndex<=0,p.disabled=this._dvCurrentIndex<0||this._dvCurrentIndex>=this._dvDiagramList.length-1,h.style.opacity=h.disabled?"0.3":"1",p.style.opacity=p.disabled?"0.3":"1"}catch(e){console.error("Error loading diagram in viewer:",e),i.innerHTML='<div style="text-align:center;padding:2rem;color:var(--text-secondary)"><i class="fas fa-exclamation-triangle" style="font-size:1.5rem;color:#f59e0b"></i><p style="margin-top:0.5rem">Error cargando diagrama</p></div>'}}_renderViewerHotspots(e,t){t.querySelectorAll(".hotspot-marker").forEach((e=>e.remove())),e&&0!==e.length&&e.forEach(((e,a)=>{const s=(e.coords||"").split(",");if(s.length<2)return;const n=parseFloat(s[0]),i=parseFloat(s[1]);if(isNaN(n)||isNaN(i))return;const r=document.createElement("div");r.className="hotspot-marker pulse",r.style.left=n+"%",r.style.top=i+"%",r.dataset.partId=e.part_id||"",r.dataset.callout=e.callout_number||a+1,r.title=e.part_name||e.label||"Parte "+(a+1),r.innerHTML=`<span class="hotspot-number">${e.callout_number||a+1}</span>`,r.addEventListener("click",(()=>{this._highlightPartInList(e.part_id),t.querySelectorAll(".hotspot-marker").forEach((e=>e.classList.remove("active"))),r.classList.add("active")})),t.appendChild(r)}))}_renderViewerParts(e,t){const a=document.getElementById("dvPartsList");if(document.getElementById("dvPartsCount").textContent=e.length,!e||0===e.length)return void(a.innerHTML='<div style="text-align:center;padding:3rem;color:var(--text-secondary)"><i class="fas fa-box-open" style="font-size:2rem;margin-bottom:0.5rem"></i><p>No hay partes vinculadas</p></div>');const s={};(t||[]).forEach(((e,t)=>{e.part_id&&(s[e.part_id]=e.callout_number||t+1)}));const n={};e.forEach((e=>{const t=e.group_name_es||e.group_name||"Otros";n[t]||(n[t]=[]),n[t].push(e)}));let i="";for(const[e,t]of Object.entries(n)){i+=`<div class="dv-group-label">${e}</div>`;for(const e of t){const t=s[e.id];let a="";e.cross_references&&e.cross_references.length>0&&(a=`<div class="dv-xref-list">${e.cross_references.map((e=>`<span class="dv-xref-tag">${e.number}</span>`)).join("")}</div>`),i+=`\n <div class="dv-part-item" data-part-id="${e.id}" onclick="dashboard._onViewerPartClick(${e.id})">\n <div style="display:flex;align-items:center;gap:0.5rem">\n ${t?`<span style="background:var(--accent);color:white;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;flex-shrink:0">${t}</span>`:""}\n <div class="dv-part-number">${e.part_number||e.oem_part_number}</div>\n </div>\n <div class="dv-part-name">${e.name_es||e.name||""}</div>\n ${a}\n </div>`}}a.innerHTML=i}_highlightPartInList(e){if(!e)return;const t=document.getElementById("dvPartsList");t.querySelectorAll(".dv-part-item").forEach((e=>e.classList.remove("highlighted")));const a=t.querySelector(`.dv-part-item[data-part-id="${e}"]`);a&&(a.classList.add("highlighted"),a.scrollIntoView({behavior:"smooth",block:"nearest"}))}_onViewerPartClick(e){this._highlightPartInList(e);document.getElementById("dvImgWrapper").querySelectorAll(".hotspot-marker").forEach((t=>{t.classList.remove("active"),t.dataset.partId==e&&t.classList.add("active")}))}_dvNavigate(e){const t=this._dvCurrentIndex+e;if(t<0||t>=this._dvDiagramList.length)return;this._dvCurrentIndex=t;const a=this._dvDiagramList[t];a&&this._loadDiagramInViewer(a.id)}_dvSetZoom(e){this._dvZoom=Math.max(.25,Math.min(4,e));const t=document.getElementById("dvImgWrapper");1!==this._dvZoom?(t.classList.add("zoomed"),t.style.transform=`scale(${this._dvZoom})`):(t.classList.remove("zoomed"),t.style.transform=""),document.getElementById("dvZoomLevel").textContent=`${Math.round(100*this._dvZoom)}%`}_bindDiagramViewerEvents(){this._dvBound||(this._dvBound=!0,this._dvHandlers={close:()=>this.closeDiagramViewer(),prev:()=>this._dvNavigate(-1),next:()=>this._dvNavigate(1),zoomIn:()=>this._dvSetZoom(this._dvZoom+.25),zoomOut:()=>this._dvSetZoom(this._dvZoom-.25),zoomFit:()=>this._dvSetZoom(1),keydown:e=>{document.getElementById("diagramViewerOverlay").classList.contains("active")&&("Escape"===e.key&&this.closeDiagramViewer(),"ArrowLeft"===e.key&&this._dvNavigate(-1),"ArrowRight"===e.key&&this._dvNavigate(1),"+"!==e.key&&"="!==e.key||this._dvSetZoom(this._dvZoom+.25),"-"===e.key&&this._dvSetZoom(this._dvZoom-.25))},wheel:e=>{if(!document.getElementById("diagramViewerOverlay").classList.contains("active"))return;e.preventDefault();const t=e.deltaY>0?-.15:.15;this._dvSetZoom(this._dvZoom+t)},partsFilter:e=>{const t=e.target.value.toLowerCase();document.querySelectorAll("#dvPartsList .dv-part-item").forEach((e=>{e.style.display=e.textContent.toLowerCase().includes(t)?"":"none"}))},mousedown:e=>{if(this._dvZoom<=1)return;this._dvDragging=!0,this._dvDragStart={x:e.clientX,y:e.clientY};const t=document.getElementById("dvImgContainer");this._dvScrollStart={x:t.scrollLeft,y:t.scrollTop},t.style.cursor="grabbing"},mousemove:e=>{if(!this._dvDragging)return;const t=document.getElementById("dvImgContainer");t.scrollLeft=this._dvScrollStart.x-(e.clientX-this._dvDragStart.x),t.scrollTop=this._dvScrollStart.y-(e.clientY-this._dvDragStart.y)},mouseup:()=>{this._dvDragging=!1;const e=document.getElementById("dvImgContainer");e&&(e.style.cursor="")}},document.getElementById("dvCloseBtn").addEventListener("click",this._dvHandlers.close),document.getElementById("dvPrevBtn").addEventListener("click",this._dvHandlers.prev),document.getElementById("dvNextBtn").addEventListener("click",this._dvHandlers.next),document.getElementById("dvZoomIn").addEventListener("click",this._dvHandlers.zoomIn),document.getElementById("dvZoomOut").addEventListener("click",this._dvHandlers.zoomOut),document.getElementById("dvZoomFit").addEventListener("click",this._dvHandlers.zoomFit),document.getElementById("dvPartsFilter").addEventListener("input",this._dvHandlers.partsFilter),document.addEventListener("keydown",this._dvHandlers.keydown),document.getElementById("dvImgContainer").addEventListener("wheel",this._dvHandlers.wheel,{passive:!1}),document.getElementById("dvImgContainer").addEventListener("mousedown",this._dvHandlers.mousedown),window.addEventListener("mousemove",this._dvHandlers.mousemove),window.addEventListener("mouseup",this._dvHandlers.mouseup))}_unbindDiagramViewerEvents(){this._dvBound&&(this._dvBound=!1,document.getElementById("dvCloseBtn")?.removeEventListener("click",this._dvHandlers.close),document.getElementById("dvPrevBtn")?.removeEventListener("click",this._dvHandlers.prev),document.getElementById("dvNextBtn")?.removeEventListener("click",this._dvHandlers.next),document.getElementById("dvZoomIn")?.removeEventListener("click",this._dvHandlers.zoomIn),document.getElementById("dvZoomOut")?.removeEventListener("click",this._dvHandlers.zoomOut),document.getElementById("dvZoomFit")?.removeEventListener("click",this._dvHandlers.zoomFit),document.getElementById("dvPartsFilter")?.removeEventListener("input",this._dvHandlers.partsFilter),document.removeEventListener("keydown",this._dvHandlers.keydown),document.getElementById("dvImgContainer")?.removeEventListener("wheel",this._dvHandlers.wheel),document.getElementById("dvImgContainer")?.removeEventListener("mousedown",this._dvHandlers.mousedown),window.removeEventListener("mousemove",this._dvHandlers.mousemove),window.removeEventListener("mouseup",this._dvHandlers.mouseup))}openVinDecoder(){document.getElementById("vinResult").innerHTML="",this.openModalWithFocus("vinDecoderModal")}async decodeVin(){const e=document.getElementById("vinInput").value.trim().toUpperCase(),t=document.getElementById("vinResult");if(e)if(17===e.length)if(/[IOQ]/i.test(e))t.innerHTML='\n <div class="alert alert-warning">\n <i class="fas fa-exclamation-triangle"></i> El VIN contiene caracteres invalidos. Las letras I, O y Q no se permiten en VINs.\n </div>\n ';else if(/^[A-HJ-NPR-Z0-9]{17}$/i.test(e)){t.innerHTML='\n <div class="text-center py-4">\n <i class="fas fa-spinner fa-spin fa-2x"></i>\n <p class="mt-2">Decodificando VIN...</p>\n </div>\n ';try{const t=await fetch(`/api/vin/decode/${encodeURIComponent(e)}`);if(!t.ok){const e=await t.json().catch((()=>({})));throw new Error(e.detail||e.message||"Error al decodificar VIN")}const a=await t.json();this.showVinResult(a,e)}catch(e){console.error("Error:",e),t.innerHTML=`\n <div class="alert alert-danger">\n <i class="fas fa-exclamation-circle"></i> ${e.message}\n </div>\n `}}else t.innerHTML='\n <div class="alert alert-warning">\n <i class="fas fa-exclamation-triangle"></i> El VIN contiene caracteres invalidos. Solo se permiten letras (excepto I, O, Q) y numeros.\n </div>\n ';else t.innerHTML=`\n <div class="alert alert-warning">\n <i class="fas fa-exclamation-triangle"></i> El VIN debe tener exactamente 17 caracteres (actual: ${e.length})\n </div>\n `;else t.innerHTML='\n <div class="alert alert-warning">\n <i class="fas fa-exclamation-triangle"></i> Por favor ingresa un VIN\n </div>\n '}showVinResult(e,t){const a=document.getElementById("vinResult"),s=e.vehicle||e,n=s.make||s.brand||"Desconocido",i=s.model||"Desconocido",r=s.year||s.model_year||"Desconocido",o=s.engine||s.engine_description||"N/A",l=s.trim||s.trim_level||"",d=(s.body_type||s.body_class,s.drive_type||s.drivetrain||"N/A"),c=s.fuel_type||"N/A",m=s.transmission||"N/A",h=s.country||s.plant_country||"N/A",p=e.matched||e.database_match||s.mye_id,g=s.mye_id||e.mye_id;let v="";v=p&&g?`\n <div class="vehicle-match-card">\n <div class="d-flex align-items-center justify-content-between">\n <div>\n <i class="fas fa-check-circle fa-2x me-2"></i>\n <strong>Vehiculo encontrado en la base de datos</strong>\n </div>\n <button class="btn btn-light" onclick="dashboard.viewVinParts('${t}', ${g})">\n <i class="fas fa-cogs"></i> Ver Partes\n </button>\n </div>\n </div>\n `:`\n <div class="vehicle-match-card vehicle-no-match">\n <div class="d-flex align-items-center justify-content-between">\n <div>\n <i class="fas fa-info-circle fa-2x me-2"></i>\n <strong>Vehiculo no encontrado en la base de datos</strong>\n </div>\n <button class="btn btn-light" onclick="dashboard.searchManuallyFromVin('${n}', '${i}', '${r}')">\n <i class="fas fa-search"></i> Buscar Manualmente\n </button>\n </div>\n </div>\n `,a.innerHTML=`\n <div class="vin-result">\n <div class="vin-result-header">\n <span class="vin-badge">${t}</span>\n <span class="badge bg-success"><i class="fas fa-check"></i> VIN Valido</span>\n </div>\n\n <h5 class="mb-3">${r} ${n} ${i} ${l}</h5>\n\n <div class="row g-3">\n <div class="col-md-6">\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-car"></i> Marca</span>\n <span class="part-detail-value">${n}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-car-side"></i> Modelo</span>\n <span class="part-detail-value">${i}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-calendar-alt"></i> Año</span>\n <span class="part-detail-value">${r}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-cogs"></i> Motor</span>\n <span class="part-detail-value">${o}</span>\n </div>\n </div>\n <div class="col-md-6">\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-gas-pump"></i> Combustible</span>\n <span class="part-detail-value">${c}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-exchange-alt"></i> Transmision</span>\n <span class="part-detail-value">${m}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-road"></i> Traccion</span>\n <span class="part-detail-value">${d}</span>\n </div>\n <div class="part-detail-row">\n <span class="part-detail-label"><i class="fas fa-globe"></i> Pais</span>\n <span class="part-detail-value">${h}</span>\n </div>\n </div>\n </div>\n\n ${v}\n </div>\n `}async viewVinParts(e,t){const a=document.getElementById("vinDecoderModal");a&&a.classList.remove("active");const s=document.getElementById("searchResultsContent");document.getElementById("searchResultsModalLabel").innerHTML=`<i class="fas fa-cogs"></i> Partes para VIN: ${e.substring(0,8)}...`,s.innerHTML='\n <div class="text-center py-4">\n <i class="fas fa-spinner fa-spin fa-2x"></i>\n <p class="mt-2">Cargando partes...</p>\n </div>\n ';this.openModalWithFocus("searchResultsModal");try{const a=await fetch(`/api/vin/${encodeURIComponent(e)}/parts`);if(!a.ok)throw new Error("Error al cargar partes");const s=await a.json(),n=s.parts||s;this.displayVinParts(n,e,t)}catch(e){console.error("Error:",e),s.innerHTML=t?`\n <div class="alert alert-info">\n <i class="fas fa-info-circle"></i> No se encontraron partes especificas para este VIN.\n <br><br>\n <button class="btn btn-primary" onclick="bootstrap.Modal.getInstance(document.getElementById('searchResultsModal')).hide(); setTimeout(() => dashboard.goToCategories(${t}), 300);">\n <i class="fas fa-folder-open"></i> Ver Categorias del Vehiculo\n </button>\n </div>\n `:`\n <div class="text-center py-4">\n <i class="fas fa-exclamation-triangle fa-2x text-warning"></i>\n <p class="mt-2">Error al cargar partes: ${e.message}</p>\n </div>\n `}}displayVinParts(e,t,a){const s=document.getElementById("searchResultsContent");if(!e||0===e.length)return void(s.innerHTML=`\n <div class="text-center py-4">\n <i class="fas fa-box-open fa-2x text-muted"></i>\n <p class="mt-2">No se encontraron partes para este VIN</p>\n ${a?`\n <button class="btn btn-primary mt-2" onclick="bootstrap.Modal.getInstance(document.getElementById('searchResultsModal')).hide(); setTimeout(() => dashboard.goToCategories(${a}), 300);">\n <i class="fas fa-folder-open"></i> Ver Categorias del Vehiculo\n </button>\n `:""}\n </div>\n `);const n={};e.forEach((e=>{const t=e.category_name_es||e.category_name||"Sin Categoria";n[t]||(n[t]=[]),n[t].push(e)}));let i=`<p class="text-muted mb-3">${e.length} parte${1!==e.length?"s":""} encontrada${1!==e.length?"s":""}</p>`;for(const[e,t]of Object.entries(n))i+=`\n <div class="search-results-section">\n <h5><i class="fas fa-folder"></i> ${e}</h5>\n <div class="search-results-list" style="max-height: none;">\n ${t.map((e=>`\n <div class="search-result-item" onclick="dashboard.showPartDetailFromSearch(${e.id})">\n <div class="d-flex justify-content-between align-items-center">\n <div>\n <div class="search-result-part-number">\n <span class="part-oem-badge">${e.oem_part_number||e.part_number||"N/A"}</span>\n </div>\n <div class="search-result-name mt-1">${e.name_es||e.name||"Sin nombre"}</div>\n </div>\n <div class="text-end">\n ${e.quality_tier?this.getQualityBadge(e.quality_tier):""}\n ${e.group_name_es||e.group_name?`<div class="text-muted small mt-1">${e.group_name_es||e.group_name}</div>`:""}\n </div>\n </div>\n </div>\n `)).join("")}\n </div>\n </div>\n `;a&&(i+=`\n <div class="mt-3 text-center">\n <button class="btn btn-primary" onclick="bootstrap.Modal.getInstance(document.getElementById('searchResultsModal')).hide(); setTimeout(() => dashboard.goToCategories(${a}), 300);">\n <i class="fas fa-folder-open"></i> Ver Todas las Categorias\n </button>\n </div>\n `),s.innerHTML=i}searchManuallyFromVin(e,t,a){const s=bootstrap.Modal.getInstance(document.getElementById("vinDecoderModal"));s&&s.hide(),setTimeout((async()=>{try{const t=await fetch("/api/brands");if(t.ok){const a=(await t.json()).find((t=>t.toLowerCase()===e.toLowerCase()||t.toLowerCase().includes(e.toLowerCase())||e.toLowerCase().includes(t.toLowerCase())));if(a)return void this.goToModels(a)}alert(`La marca "${e}" no se encontro en la base de datos. Mostrando todas las marcas disponibles.`),this.goToBrands()}catch(e){console.error("Error:",e),this.goToBrands()}}),300)}}let dashboard;document.addEventListener("DOMContentLoaded",(()=>{dashboard=new VehicleDashboard}));