/** * Admin Panel JavaScript * CRUD operations and CSV import/export for Nexus Autoparts */ // State let currentPage = { parts: 1, aftermarket: 1, crossref: 1, fitment: 1 }; let categoriesCache = []; let groupsCache = []; let partsCache = []; let manufacturersCache = []; let brandsCache = []; let pendingImportData = null; let searchTimeout = null; // CSV Format definitions const csvFormats = { parts: { columns: ['oem_part_number', 'name', 'name_es', 'group_id', 'description', 'description_es', 'weight_kg', 'material'], required: ['oem_part_number', 'name', 'group_id'], example: 'oem_part_number,name,name_es,group_id,description,description_es,weight_kg,material\n04465-33450,Brake Pad Set Front,Pastillas de Freno Delanteras,5,Front disc brake pads,Pastillas de freno de disco delanteras,1.2,Ceramic' }, aftermarket: { columns: ['oem_part_id', 'manufacturer_id', 'part_number', 'name', 'name_es', 'quality_tier', 'price_usd', 'warranty_months'], required: ['oem_part_id', 'manufacturer_id', 'part_number'], example: 'oem_part_id,manufacturer_id,part_number,name,name_es,quality_tier,price_usd,warranty_months\n1,3,BP-1234,Brake Pad Set,Pastillas de Freno,premium,45.99,24' }, manufacturers: { columns: ['name', 'type', 'quality_tier', 'country', 'website'], required: ['name', 'type'], example: 'name,type,quality_tier,country,website\nBrembo,aftermarket,premium,Italy,https://www.brembo.com' }, categories: { columns: ['name', 'name_es', 'slug', 'icon_name', 'display_order'], required: ['name'], example: 'name,name_es,slug,icon_name,display_order\nBrake System,Sistema de Frenos,brakes,brake,1' }, groups: { columns: ['category_id', 'name', 'name_es', 'display_order'], required: ['category_id', 'name'], example: 'category_id,name,name_es,display_order\n1,Brake Pads,Pastillas de Freno,1' }, crossref: { columns: ['part_id', 'cross_reference_number', 'reference_type', 'source', 'notes'], required: ['part_id', 'cross_reference_number', 'reference_type'], example: 'part_id,cross_reference_number,reference_type,source,notes\n1,D1210,interchange,Manufacturer,Compatible replacement' }, fitment: { columns: ['model_year_engine_id', 'part_id', 'quantity_required', 'position', 'fitment_notes'], required: ['model_year_engine_id', 'part_id'], example: 'model_year_engine_id,part_id,quantity_required,position,fitment_notes\n1,1,2,front,Fits all trims' } }; // Initialize document.addEventListener('DOMContentLoaded', () => { initSidebar(); initDropZone(); initImportTypeChange(); loadDashboard(); }); // Sidebar navigation function initSidebar() { const items = document.querySelectorAll('.sidebar-item'); items.forEach(item => { item.addEventListener('click', () => { const section = item.dataset.section; showSection(section); items.forEach(i => i.classList.remove('active')); item.classList.add('active'); }); }); } function showSection(sectionId) { // Hide all sections document.querySelectorAll('.admin-section').forEach(s => s.classList.remove('active')); // Show selected section const section = document.getElementById(`section-${sectionId}`); if (section) { section.classList.add('active'); // Load data for the section switch (sectionId) { case 'dashboard': loadDashboard(); break; case 'categories': loadCategories(); break; case 'groups': loadGroups(); break; case 'parts': loadParts(); break; case 'manufacturers': loadManufacturers(); break; case 'aftermarket': loadAftermarket(); break; case 'crossref': loadCrossRefs(); break; case 'fitment': loadFitment(); break; case 'diagrams': // Just show section, user uses search break; } } // Update sidebar active state document.querySelectorAll('.sidebar-item').forEach(item => { item.classList.toggle('active', item.dataset.section === sectionId); }); } // Modal functions function openModal(modalId) { document.getElementById(modalId).classList.add('active'); } function closeModal(modalId) { document.getElementById(modalId).classList.remove('active'); } // Alert function function showAlert(message, type = 'success') { const container = document.getElementById('alertContainer'); const alert = document.createElement('div'); alert.className = `alert alert-${type}`; alert.innerHTML = `${type === 'success' ? '✓' : '✕'} ${message}`; container.appendChild(alert); setTimeout(() => alert.remove(), 5000); } // Debounce search function debounceSearch(callback) { if (searchTimeout) clearTimeout(searchTimeout); searchTimeout = setTimeout(callback, 300); } // ============================================================================ // Dashboard // ============================================================================ async function loadDashboard() { try { const stats = await fetch('/api/admin/stats').then(r => r.json()); document.getElementById('statCategories').textContent = stats.categories || 0; document.getElementById('statGroups').textContent = stats.groups || 0; document.getElementById('statParts').textContent = stats.parts || 0; document.getElementById('statAftermarket').textContent = stats.aftermarket || 0; document.getElementById('statManufacturers').textContent = stats.manufacturers || 0; document.getElementById('statFitment').textContent = stats.fitment || 0; } catch (e) { console.error('Error loading dashboard:', e); } } // ============================================================================ // Categories CRUD // ============================================================================ async function loadCategories() { try { const response = await fetch('/api/categories'); const categories = await response.json(); categoriesCache = flattenCategories(categories); const tbody = document.getElementById('categoriesTable'); if (categoriesCache.length === 0) { tbody.innerHTML = 'No hay categorías'; return; } tbody.innerHTML = categoriesCache.map(cat => ` ${cat.id} ${cat.name} ${cat.name_es || '-'} ${cat.slug || '-'} ${cat.icon_name || '-'} ${cat.display_order || 0} `).join(''); // Update category selects updateCategorySelects(); } catch (e) { console.error('Error loading categories:', e); showAlert('Error al cargar categorías', 'error'); } } function flattenCategories(categories, level = 0) { let result = []; categories.forEach(cat => { result.push({ ...cat, level }); if (cat.children && cat.children.length > 0) { result = result.concat(flattenCategories(cat.children, level + 1)); } }); return result; } function updateCategorySelects() { const selects = ['groupCategoryFilter', 'groupCategory']; selects.forEach(id => { const select = document.getElementById(id); if (!select) return; const currentValue = select.value; const defaultOption = id.includes('Filter') ? '' : ''; select.innerHTML = defaultOption + categoriesCache.map(cat => `` ).join(''); select.value = currentValue; }); } function openCategoryModal(id = null) { document.getElementById('categoryId').value = ''; document.getElementById('categoryName').value = ''; document.getElementById('categoryNameEs').value = ''; document.getElementById('categorySlug').value = ''; document.getElementById('categoryIcon').value = ''; document.getElementById('categoryOrder').value = '0'; document.getElementById('categoryModalTitle').textContent = 'Nueva Categoría'; if (id) { const cat = categoriesCache.find(c => c.id === id); if (cat) { document.getElementById('categoryId').value = cat.id; document.getElementById('categoryName').value = cat.name; document.getElementById('categoryNameEs').value = cat.name_es || ''; document.getElementById('categorySlug').value = cat.slug || ''; document.getElementById('categoryIcon').value = cat.icon_name || ''; document.getElementById('categoryOrder').value = cat.display_order || 0; document.getElementById('categoryModalTitle').textContent = 'Editar Categoría'; } } openModal('categoryModal'); } function editCategory(id) { openCategoryModal(id); } async function saveCategory() { const id = document.getElementById('categoryId').value; const data = { name: document.getElementById('categoryName').value, name_es: document.getElementById('categoryNameEs').value || null, slug: document.getElementById('categorySlug').value || null, icon_name: document.getElementById('categoryIcon').value || null, display_order: parseInt(document.getElementById('categoryOrder').value) || 0 }; try { const url = id ? `/api/admin/categories/${id}` : '/api/admin/categories'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('categoryModal'); showAlert(id ? 'Categoría actualizada' : 'Categoría creada'); loadCategories(); } catch (e) { showAlert(e.message, 'error'); } } async function deleteCategory(id) { if (!confirm('¿Estás seguro de eliminar esta categoría?')) return; try { const response = await fetch(`/api/admin/categories/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Categoría eliminada'); loadCategories(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Groups CRUD // ============================================================================ async function loadGroups() { try { const categoryId = document.getElementById('groupCategoryFilter').value; let url = '/api/admin/groups'; if (categoryId) url += `?category_id=${categoryId}`; const response = await fetch(url); const groups = await response.json(); groupsCache = groups; const tbody = document.getElementById('groupsTable'); if (groups.length === 0) { tbody.innerHTML = 'No hay grupos'; return; } tbody.innerHTML = groups.map(group => ` ${group.id} ${group.name} ${group.name_es || '-'} ${group.category_name || '-'} ${group.display_order || 0} `).join(''); updateGroupSelects(); } catch (e) { console.error('Error loading groups:', e); showAlert('Error al cargar grupos', 'error'); } } function updateGroupSelects() { const selects = ['partGroupFilter', 'partGroup']; selects.forEach(id => { const select = document.getElementById(id); if (!select) return; const currentValue = select.value; const defaultOption = id.includes('Filter') ? '' : ''; select.innerHTML = defaultOption + groupsCache.map(g => `` ).join(''); select.value = currentValue; }); } function openGroupModal(id = null) { document.getElementById('groupId').value = ''; document.getElementById('groupName').value = ''; document.getElementById('groupNameEs').value = ''; document.getElementById('groupOrder').value = '0'; document.getElementById('groupModalTitle').textContent = 'Nuevo Grupo'; // Populate category select const select = document.getElementById('groupCategory'); select.innerHTML = '' + categoriesCache.map(cat => ``).join(''); if (id) { const group = groupsCache.find(g => g.id === id); if (group) { document.getElementById('groupId').value = group.id; document.getElementById('groupName').value = group.name; document.getElementById('groupNameEs').value = group.name_es || ''; document.getElementById('groupCategory').value = group.category_id; document.getElementById('groupOrder').value = group.display_order || 0; document.getElementById('groupModalTitle').textContent = 'Editar Grupo'; } } openModal('groupModal'); } function editGroup(id) { openGroupModal(id); } async function saveGroup() { const id = document.getElementById('groupId').value; const data = { category_id: parseInt(document.getElementById('groupCategory').value), name: document.getElementById('groupName').value, name_es: document.getElementById('groupNameEs').value || null, display_order: parseInt(document.getElementById('groupOrder').value) || 0 }; try { const url = id ? `/api/admin/groups/${id}` : '/api/admin/groups'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('groupModal'); showAlert(id ? 'Grupo actualizado' : 'Grupo creado'); loadGroups(); } catch (e) { showAlert(e.message, 'error'); } } async function deleteGroup(id) { if (!confirm('¿Estás seguro de eliminar este grupo?')) return; try { const response = await fetch(`/api/admin/groups/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Grupo eliminado'); loadGroups(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Parts CRUD // ============================================================================ async function loadParts() { try { const search = document.getElementById('partSearch').value; const groupId = document.getElementById('partGroupFilter').value; let url = `/api/parts?page=${currentPage.parts}&per_page=20`; if (search) url += `&search=${encodeURIComponent(search)}`; if (groupId) url += `&group_id=${groupId}`; const response = await fetch(url); const result = await response.json(); partsCache = result.data || []; const tbody = document.getElementById('partsTable'); if (partsCache.length === 0) { tbody.innerHTML = 'No hay partes'; renderPagination('partsPagination', result.pagination, 'parts', loadParts); return; } tbody.innerHTML = partsCache.map(part => ` ${part.id} ${part.image_url ? `${part.name}` : '📷'} ${part.oem_part_number} ${part.name} ${part.group_name || '-'} `).join(''); renderPagination('partsPagination', result.pagination, 'parts', loadParts); // Load groups if not already loaded if (groupsCache.length === 0) { await loadGroups(); } } catch (e) { console.error('Error loading parts:', e); showAlert('Error al cargar partes', 'error'); } } function openPartModal(id = null) { document.getElementById('partId').value = ''; document.getElementById('partOemNumber').value = ''; document.getElementById('partName').value = ''; document.getElementById('partNameEs').value = ''; document.getElementById('partDescription').value = ''; document.getElementById('partDescriptionEs').value = ''; document.getElementById('partWeight').value = ''; document.getElementById('partMaterial').value = ''; document.getElementById('partModalTitle').textContent = 'Nueva Parte OEM'; // Reset image preview resetPartImagePreview(); // Populate group select const select = document.getElementById('partGroup'); select.innerHTML = '' + groupsCache.map(g => ``).join(''); if (id) { fetchPartDetail(id); } openModal('partModal'); } async function fetchPartDetail(id) { try { const response = await fetch(`/api/parts/${id}`); const part = await response.json(); document.getElementById('partId').value = part.id; document.getElementById('partOemNumber').value = part.oem_part_number; document.getElementById('partName').value = part.name; document.getElementById('partNameEs').value = part.name_es || ''; document.getElementById('partGroup').value = part.group_id; document.getElementById('partDescription').value = part.description || ''; document.getElementById('partDescriptionEs').value = part.description_es || ''; document.getElementById('partModalTitle').textContent = 'Editar Parte OEM'; // Show existing image const preview = document.getElementById('partImagePreview'); if (part.image_url) { preview.innerHTML = `${part.name}`; document.getElementById('partImageUrl').value = part.image_url; } else { resetPartImagePreview(); } } catch (e) { console.error('Error fetching part:', e); } } function editPart(id) { openPartModal(id); } async function savePart() { const id = document.getElementById('partId').value; // Get image URL (from file upload or manual URL input) let imageUrl = document.getElementById('partImageUrl').value || null; const imagePreview = document.getElementById('partImagePreview').querySelector('img'); if (imagePreview && imagePreview.src.startsWith('data:')) { // If it's a base64 image from file upload, we'll upload it first imageUrl = await uploadPartImage(imagePreview.src); } const data = { oem_part_number: document.getElementById('partOemNumber').value, name: document.getElementById('partName').value, name_es: document.getElementById('partNameEs').value || null, group_id: parseInt(document.getElementById('partGroup').value), description: document.getElementById('partDescription').value || null, description_es: document.getElementById('partDescriptionEs').value || null, weight_kg: parseFloat(document.getElementById('partWeight').value) || null, material: document.getElementById('partMaterial').value || null, image_url: imageUrl }; try { const url = id ? `/api/admin/parts/${id}` : '/api/admin/parts'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('partModal'); showAlert(id ? 'Parte actualizada' : 'Parte creada'); loadParts(); } catch (e) { showAlert(e.message, 'error'); } } // Image handling functions function previewPartImage(input) { const preview = document.getElementById('partImagePreview'); if (input.files && input.files[0]) { const reader = new FileReader(); reader.onload = function(e) { preview.innerHTML = `Preview`; }; reader.readAsDataURL(input.files[0]); } } async function uploadPartImage(base64Data) { try { const response = await fetch('/api/admin/upload-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: base64Data }) }); if (!response.ok) throw new Error('Error uploading image'); const result = await response.json(); return result.url; } catch (e) { console.error('Image upload failed:', e); return null; } } function resetPartImagePreview() { const preview = document.getElementById('partImagePreview'); preview.innerHTML = '📷 Sin imagen'; document.getElementById('partImageUrl').value = ''; document.getElementById('partImageFile').value = ''; } async function deletePart(id) { if (!confirm('¿Estás seguro de eliminar esta parte?')) return; try { const response = await fetch(`/api/admin/parts/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Parte eliminada'); loadParts(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Manufacturers CRUD // ============================================================================ async function loadManufacturers() { try { const response = await fetch('/api/manufacturers'); manufacturersCache = await response.json(); const tbody = document.getElementById('manufacturersTable'); if (manufacturersCache.length === 0) { tbody.innerHTML = 'No hay fabricantes'; return; } tbody.innerHTML = manufacturersCache.map(m => ` ${m.id} ${m.name} ${m.type || '-'} ${m.quality_tier || '-'} ${m.country || '-'} `).join(''); updateManufacturerSelects(); } catch (e) { console.error('Error loading manufacturers:', e); showAlert('Error al cargar fabricantes', 'error'); } } function updateManufacturerSelects() { const select = document.getElementById('aftermarketManufacturerFilter'); if (select) { const currentValue = select.value; select.innerHTML = '' + manufacturersCache.map(m => ``).join(''); select.value = currentValue; } const modalSelect = document.getElementById('aftermarketManufacturer'); if (modalSelect) { modalSelect.innerHTML = '' + manufacturersCache.map(m => ``).join(''); } } function openManufacturerModal(id = null) { document.getElementById('manufacturerId').value = ''; document.getElementById('manufacturerName').value = ''; document.getElementById('manufacturerType').value = 'aftermarket'; document.getElementById('manufacturerQuality').value = 'standard'; document.getElementById('manufacturerCountry').value = ''; document.getElementById('manufacturerWebsite').value = ''; document.getElementById('manufacturerModalTitle').textContent = 'Nuevo Fabricante'; if (id) { const m = manufacturersCache.find(x => x.id === id); if (m) { document.getElementById('manufacturerId').value = m.id; document.getElementById('manufacturerName').value = m.name; document.getElementById('manufacturerType').value = m.type || 'aftermarket'; document.getElementById('manufacturerQuality').value = m.quality_tier || 'standard'; document.getElementById('manufacturerCountry').value = m.country || ''; document.getElementById('manufacturerWebsite').value = m.website || ''; document.getElementById('manufacturerModalTitle').textContent = 'Editar Fabricante'; } } openModal('manufacturerModal'); } function editManufacturer(id) { openManufacturerModal(id); } async function saveManufacturer() { const id = document.getElementById('manufacturerId').value; const data = { name: document.getElementById('manufacturerName').value, type: document.getElementById('manufacturerType').value, quality_tier: document.getElementById('manufacturerQuality').value, country: document.getElementById('manufacturerCountry').value || null, website: document.getElementById('manufacturerWebsite').value || null }; try { const url = id ? `/api/admin/manufacturers/${id}` : '/api/admin/manufacturers'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('manufacturerModal'); showAlert(id ? 'Fabricante actualizado' : 'Fabricante creado'); loadManufacturers(); } catch (e) { showAlert(e.message, 'error'); } } async function deleteManufacturer(id) { if (!confirm('¿Estás seguro de eliminar este fabricante?')) return; try { const response = await fetch(`/api/admin/manufacturers/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Fabricante eliminado'); loadManufacturers(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Aftermarket CRUD // ============================================================================ async function loadAftermarket() { try { const search = document.getElementById('aftermarketSearch').value; const manufacturerId = document.getElementById('aftermarketManufacturerFilter').value; let url = `/api/aftermarket?page=${currentPage.aftermarket}&per_page=20`; if (search) url += `&search=${encodeURIComponent(search)}`; if (manufacturerId) url += `&manufacturer_id=${manufacturerId}`; const response = await fetch(url); const result = await response.json(); const tbody = document.getElementById('aftermarketTable'); if (!result.data || result.data.length === 0) { tbody.innerHTML = 'No hay partes aftermarket'; renderPagination('aftermarketPagination', result.pagination, 'aftermarket', loadAftermarket); return; } tbody.innerHTML = result.data.map(part => ` ${part.id} ${part.part_number} ${part.name || '-'} ${part.oem_part_number} ${part.manufacturer_name} ${part.quality_tier || '-'} ${part.price_usd ? '$' + part.price_usd.toFixed(2) : '-'} `).join(''); renderPagination('aftermarketPagination', result.pagination, 'aftermarket', loadAftermarket); } catch (e) { console.error('Error loading aftermarket:', e); showAlert('Error al cargar partes aftermarket', 'error'); } } async function openAftermarketModal(id = null) { document.getElementById('aftermarketId').value = ''; document.getElementById('aftermarketPartNumber').value = ''; document.getElementById('aftermarketName').value = ''; document.getElementById('aftermarketNameEs').value = ''; document.getElementById('aftermarketQuality').value = 'standard'; document.getElementById('aftermarketPrice').value = ''; document.getElementById('aftermarketWarranty').value = ''; document.getElementById('aftermarketModalTitle').textContent = 'Nueva Parte Aftermarket'; // Populate OEM parts select await loadPartsForSelect('aftermarketOemPart'); // Populate manufacturers updateManufacturerSelects(); openModal('aftermarketModal'); } async function editAftermarket(id) { await openAftermarketModal(id); try { const res = await fetch(`/api/aftermarket?search=&per_page=200`); const data = await res.json(); const items = data.data || data; const item = items.find(a => a.id === id); if (item) { document.getElementById('aftermarketId').value = item.id; document.getElementById('aftermarketPartNumber').value = item.part_number || ''; document.getElementById('aftermarketName').value = item.name || ''; document.getElementById('aftermarketNameEs').value = item.name_es || ''; document.getElementById('aftermarketQuality').value = item.quality_tier || 'standard'; document.getElementById('aftermarketPrice').value = item.price_usd || ''; document.getElementById('aftermarketWarranty').value = item.warranty_months || ''; if (item.oem_part_id) document.getElementById('aftermarketOemPart').value = item.oem_part_id; if (item.manufacturer_id) document.getElementById('aftermarketManufacturer').value = item.manufacturer_id; document.getElementById('aftermarketModalTitle').textContent = 'Editar Parte Aftermarket'; } } catch (e) { console.error('Error loading aftermarket for edit:', e); } } async function saveAftermarket() { const id = document.getElementById('aftermarketId').value; const data = { oem_part_id: parseInt(document.getElementById('aftermarketOemPart').value), manufacturer_id: parseInt(document.getElementById('aftermarketManufacturer').value), part_number: document.getElementById('aftermarketPartNumber').value, name: document.getElementById('aftermarketName').value || null, name_es: document.getElementById('aftermarketNameEs').value || null, quality_tier: document.getElementById('aftermarketQuality').value, price_usd: parseFloat(document.getElementById('aftermarketPrice').value) || null, warranty_months: parseInt(document.getElementById('aftermarketWarranty').value) || null }; try { const url = id ? `/api/admin/aftermarket/${id}` : '/api/admin/aftermarket'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('aftermarketModal'); showAlert(id ? 'Parte actualizada' : 'Parte creada'); loadAftermarket(); } catch (e) { showAlert(e.message, 'error'); } } async function deleteAftermarket(id) { if (!confirm('¿Estás seguro de eliminar esta parte aftermarket?')) return; try { const response = await fetch(`/api/admin/aftermarket/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Parte eliminada'); loadAftermarket(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Cross-References CRUD // ============================================================================ async function loadCrossRefs() { try { const response = await fetch(`/api/admin/crossref?page=${currentPage.crossref}&per_page=20`); const result = await response.json(); const tbody = document.getElementById('crossrefTable'); if (!result.data || result.data.length === 0) { tbody.innerHTML = 'No hay cross-references'; return; } tbody.innerHTML = result.data.map(ref => ` ${ref.id} ${ref.oem_part_number} - ${ref.part_name} ${ref.cross_reference_number} ${ref.reference_type} ${ref.source || '-'} `).join(''); renderPagination('crossrefPagination', result.pagination, 'crossref', loadCrossRefs); } catch (e) { console.error('Error loading cross-refs:', e); } } async function openCrossRefModal(id = null) { document.getElementById('crossrefId').value = ''; document.getElementById('crossrefNumber').value = ''; document.getElementById('crossrefType').value = 'interchange'; document.getElementById('crossrefSource').value = ''; document.getElementById('crossrefNotes').value = ''; document.getElementById('crossrefModalTitle').textContent = 'Nueva Cross-Reference'; await loadPartsForSelect('crossrefPart'); openModal('crossrefModal'); } function editCrossRef(id) { openCrossRefModal(id); } async function saveCrossRef() { const id = document.getElementById('crossrefId').value; const data = { part_id: parseInt(document.getElementById('crossrefPart').value), cross_reference_number: document.getElementById('crossrefNumber').value, reference_type: document.getElementById('crossrefType').value, source: document.getElementById('crossrefSource').value || null, notes: document.getElementById('crossrefNotes').value || null }; try { const url = id ? `/api/admin/crossref/${id}` : '/api/admin/crossref'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('crossrefModal'); showAlert(id ? 'Referencia actualizada' : 'Referencia creada'); loadCrossRefs(); } catch (e) { showAlert(e.message, 'error'); } } async function deleteCrossRef(id) { if (!confirm('¿Estás seguro de eliminar esta referencia?')) return; try { const response = await fetch(`/api/admin/crossref/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Referencia eliminada'); loadCrossRefs(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Fitment CRUD // ============================================================================ async function loadFitment() { try { const brand = document.getElementById('fitmentBrandFilter').value; const model = document.getElementById('fitmentModelFilter').value; let url = `/api/admin/fitment?page=${currentPage.fitment}&per_page=20`; if (brand) url += `&brand=${encodeURIComponent(brand)}`; if (model) url += `&model=${encodeURIComponent(model)}`; const response = await fetch(url); const result = await response.json(); const tbody = document.getElementById('fitmentTable'); if (!result.data || result.data.length === 0) { tbody.innerHTML = 'No hay fitments'; renderPagination('fitmentPagination', result.pagination, 'fitment', loadFitment); return; } tbody.innerHTML = result.data.map(f => ` ${f.id} ${f.brand} ${f.model} ${f.year} - ${f.engine} ${f.oem_part_number} - ${f.part_name} ${f.quantity_required} ${f.position || '-'} `).join(''); renderPagination('fitmentPagination', result.pagination, 'fitment', loadFitment); } catch (e) { console.error('Error loading fitment:', e); } } async function loadFitmentModels() { const brand = document.getElementById('fitmentBrandFilter').value; const modelSelect = document.getElementById('fitmentModelFilter'); if (!brand) { modelSelect.innerHTML = ''; loadFitment(); return; } try { const response = await fetch(`/api/models?brand=${encodeURIComponent(brand)}`); const models = await response.json(); modelSelect.innerHTML = '' + models.map(m => ``).join(''); loadFitment(); } catch (e) { console.error('Error loading models:', e); } } async function openFitmentModal() { document.getElementById('fitmentId').value = ''; document.getElementById('fitmentQuantity').value = '1'; document.getElementById('fitmentPosition').value = ''; document.getElementById('fitmentNotes').value = ''; document.getElementById('fitmentModalTitle').textContent = 'Nuevo Fitment'; // Load vehicles await loadVehiclesForSelect('fitmentVehicle'); // Load parts await loadPartsForSelect('fitmentPart'); openModal('fitmentModal'); } async function saveFitment() { const id = document.getElementById('fitmentId').value; const data = { model_year_engine_id: parseInt(document.getElementById('fitmentVehicle').value), part_id: parseInt(document.getElementById('fitmentPart').value), quantity_required: parseInt(document.getElementById('fitmentQuantity').value) || 1, position: document.getElementById('fitmentPosition').value || null, fitment_notes: document.getElementById('fitmentNotes').value || null }; try { const url = id ? `/api/admin/fitment/${id}` : '/api/admin/fitment'; const method = id ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al guardar'); } closeModal('fitmentModal'); showAlert(id ? 'Fitment actualizado' : 'Fitment creado'); loadFitment(); } catch (e) { showAlert(e.message, 'error'); } } async function deleteFitment(id) { if (!confirm('¿Estás seguro de eliminar este fitment?')) return; try { const response = await fetch(`/api/admin/fitment/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Error al eliminar'); showAlert('Fitment eliminado'); loadFitment(); } catch (e) { showAlert(e.message, 'error'); } } // ============================================================================ // Helper functions for selects // ============================================================================ async function loadPartsForSelect(selectId) { const select = document.getElementById(selectId); if (!select) return; try { const response = await fetch('/api/parts?per_page=100'); const result = await response.json(); select.innerHTML = '' + (result.data || []).map(p => `` ).join(''); } catch (e) { console.error('Error loading parts for select:', e); } } async function loadVehiclesForSelect(selectId) { const select = document.getElementById(selectId); if (!select) return; try { const response = await fetch('/api/model-year-engine?per_page=100'); const result = await response.json(); const vehicles = result.data || result; select.innerHTML = '' + vehicles.map(v => `` ).join(''); } catch (e) { console.error('Error loading vehicles for select:', e); } } async function loadBrands() { try { const response = await fetch('/api/brands'); brandsCache = await response.json(); const select = document.getElementById('fitmentBrandFilter'); if (select) { select.innerHTML = '' + brandsCache.map(b => ``).join(''); } } catch (e) { console.error('Error loading brands:', e); } } // ============================================================================ // Pagination // ============================================================================ function renderPagination(containerId, pagination, pageKey, loadFunction) { const container = document.getElementById(containerId); if (!container || !pagination) { container.innerHTML = ''; return; } const { page, total_pages } = pagination; if (total_pages <= 1) { container.innerHTML = ''; return; } let html = ''; // Previous button html += ``; // Page numbers const startPage = Math.max(1, page - 2); const endPage = Math.min(total_pages, page + 2); for (let i = startPage; i <= endPage; i++) { html += ``; } // Next button html += ``; container.innerHTML = html; } function goToPage(pageKey, page, loadFunctionName) { currentPage[pageKey] = page; window[loadFunctionName](); } // ============================================================================ // CSV Import/Export // ============================================================================ function initDropZone() { const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('csvFile'); dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragover'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); const file = e.dataTransfer.files[0]; if (file && file.name.endsWith('.csv')) { handleCsvFile(file); } else { showAlert('Por favor selecciona un archivo CSV', 'error'); } }); fileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { handleCsvFile(file); } }); } function initImportTypeChange() { const select = document.getElementById('importType'); select.addEventListener('change', updateCsvFormatHelp); updateCsvFormatHelp(); } function updateCsvFormatHelp() { const type = document.getElementById('importType').value; const format = csvFormats[type]; const helpDiv = document.getElementById('csvFormatHelp'); if (!format) return; helpDiv.innerHTML = `

Columnas requeridas: ${format.required.join(', ')}

Todas las columnas: ${format.columns.join(', ')}

Ejemplo:

${format.example}
`; } function handleCsvFile(file) { const reader = new FileReader(); reader.onload = (e) => { const content = e.target.result; const rows = parseCSV(content); if (rows.length < 2) { showAlert('El archivo CSV debe tener al menos un encabezado y una fila de datos', 'error'); return; } pendingImportData = rows; showImportPreview(rows); }; reader.readAsText(file); } function parseCSV(content) { const lines = content.split(/\r?\n/).filter(line => line.trim()); const rows = []; for (const line of lines) { const row = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { row.push(current.trim()); current = ''; } else { current += char; } } row.push(current.trim()); rows.push(row); } return rows; } function showImportPreview(rows) { const headers = rows[0]; const data = rows.slice(1, 6); // Show first 5 rows document.getElementById('previewCount').textContent = rows.length - 1; document.getElementById('previewHead').innerHTML = '' + headers.map(h => `${h}`).join('') + ''; document.getElementById('previewBody').innerHTML = data.map(row => '' + row.map(cell => `${cell}`).join('') + '' ).join(''); document.getElementById('importPreview').style.display = 'block'; } function cancelImport() { pendingImportData = null; document.getElementById('importPreview').style.display = 'none'; document.getElementById('csvFile').value = ''; } async function executeImport() { if (!pendingImportData) return; const type = document.getElementById('importType').value; const format = csvFormats[type]; const headers = pendingImportData[0]; const data = pendingImportData.slice(1); // Validate required columns for (const req of format.required) { if (!headers.includes(req)) { showAlert(`Falta columna requerida: ${req}`, 'error'); return; } } // Convert to objects const records = data.map(row => { const obj = {}; headers.forEach((h, i) => { let value = row[i] || null; // Convert numeric fields if (['id', 'category_id', 'group_id', 'part_id', 'oem_part_id', 'manufacturer_id', 'model_year_engine_id', 'display_order', 'quantity_required', 'warranty_months'].includes(h)) { value = value ? parseInt(value) : null; } else if (['weight_kg', 'price_usd'].includes(h)) { value = value ? parseFloat(value) : null; } obj[h] = value; }); return obj; }); try { const response = await fetch(`/api/admin/import/${type}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ records }) }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Error en la importación'); } showAlert(`Importados ${result.imported} registros exitosamente`); cancelImport(); loadDashboard(); } catch (e) { showAlert(e.message, 'error'); } } async function exportData(type) { try { const response = await fetch(`/api/admin/export/${type}`); const result = await response.json(); if (!result.data || result.data.length === 0) { showAlert('No hay datos para exportar', 'error'); return; } // Convert to CSV const headers = Object.keys(result.data[0]); let csv = headers.join(',') + '\n'; for (const row of result.data) { csv += headers.map(h => { let val = row[h]; if (val === null || val === undefined) return ''; val = String(val); if (val.includes(',') || val.includes('"') || val.includes('\n')) { return '"' + val.replace(/"/g, '""') + '"'; } return val; }).join(',') + '\n'; } // Download const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${type}_export_${new Date().toISOString().slice(0, 10)}.csv`; link.click(); showAlert(`Exportados ${result.data.length} registros`); } catch (e) { showAlert('Error al exportar: ' + e.message, 'error'); } } // Load brands on page load for fitment filter loadBrands(); // ============================================================================ // Bulk Fitment Editor // ============================================================================ let bulkSelectedMYEId = null; let bulkAvailableParts = []; async function initBulkEditor() { // Populate bulk brand select const bulkBrand = document.getElementById('bulkBrand'); if (bulkBrand && brandsCache.length > 0) { bulkBrand.innerHTML = '' + brandsCache.map(b => ``).join(''); } // Populate categories for filter if (categoriesCache.length === 0) { await loadCategories(); } const bulkCategory = document.getElementById('bulkCategory'); if (bulkCategory) { bulkCategory.innerHTML = '' + categoriesCache.map(c => ``).join(''); } } async function loadBulkModels() { const brand = document.getElementById('bulkBrand').value; const modelSelect = document.getElementById('bulkModel'); const yearSelect = document.getElementById('bulkYear'); const engineSelect = document.getElementById('bulkEngine'); // Reset dependent selects modelSelect.innerHTML = ''; yearSelect.innerHTML = ''; engineSelect.innerHTML = ''; document.getElementById('bulkVehicleSelected').style.display = 'none'; if (!brand) return; try { const response = await fetch(`/api/models?brand=${encodeURIComponent(brand)}`); const models = await response.json(); modelSelect.innerHTML = '' + models.map(m => ``).join(''); } catch (e) { console.error('Error loading models:', e); } } async function loadBulkYears() { const brand = document.getElementById('bulkBrand').value; const model = document.getElementById('bulkModel').value; const yearSelect = document.getElementById('bulkYear'); const engineSelect = document.getElementById('bulkEngine'); yearSelect.innerHTML = ''; engineSelect.innerHTML = ''; document.getElementById('bulkVehicleSelected').style.display = 'none'; if (!brand || !model) return; try { const response = await fetch(`/api/years?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`); const years = await response.json(); yearSelect.innerHTML = '' + years.map(y => ``).join(''); } catch (e) { console.error('Error loading years:', e); } } async function loadBulkEngines() { const brand = document.getElementById('bulkBrand').value; const model = document.getElementById('bulkModel').value; const year = document.getElementById('bulkYear').value; const engineSelect = document.getElementById('bulkEngine'); engineSelect.innerHTML = ''; document.getElementById('bulkVehicleSelected').style.display = 'none'; if (!brand || !model || !year) return; try { const response = await fetch(`/api/engines?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}`); const engines = await response.json(); // Get MYE IDs for each engine const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}&per_page=100`); const myeResult = await myeResponse.json(); const myeData = myeResult.data || myeResult; engineSelect.innerHTML = '' + myeData.map(mye => ``).join(''); } catch (e) { console.error('Error loading engines:', e); } } async function selectBulkVehicle() { const myeId = document.getElementById('bulkEngine').value; if (!myeId) { document.getElementById('bulkVehicleSelected').style.display = 'none'; return; } bulkSelectedMYEId = parseInt(myeId); const brand = document.getElementById('bulkBrand').value; const model = document.getElementById('bulkModel').value; const year = document.getElementById('bulkYear').value; const engine = document.getElementById('bulkEngine').options[document.getElementById('bulkEngine').selectedIndex].text; document.getElementById('bulkVehicleName').textContent = `${brand} ${model} ${year} - ${engine}`; document.getElementById('bulkMYEId').textContent = myeId; document.getElementById('bulkVehicleSelected').style.display = 'block'; // Initialize categories and load parts await initBulkEditor(); await loadBulkParts(); } async function loadBulkParts() { const container = document.getElementById('bulkPartsContainer'); const categoryId = document.getElementById('bulkCategory').value; container.innerHTML = '

Cargando partes...

'; try { let url = '/api/parts?per_page=100'; if (categoryId) url += `&category_id=${categoryId}`; const response = await fetch(url); const result = await response.json(); bulkAvailableParts = result.data || []; if (bulkAvailableParts.length === 0) { container.innerHTML = '

No hay partes disponibles en esta categoría.

'; return; } // Get existing fitments for this vehicle to mark them const fitmentResponse = await fetch(`/api/admin/fitment?mye_id=${bulkSelectedMYEId}&per_page=500`); const fitmentResult = await fitmentResponse.json(); const existingPartIds = new Set((fitmentResult.data || []).map(f => f.part_id)); container.innerHTML = bulkAvailableParts.map(part => `
${part.oem_part_number}
${part.name}
${part.group_name || ''} · ${part.category_name || ''}
`).join(''); updateBulkSelectedCount(); } catch (e) { console.error('Error loading parts:', e); container.innerHTML = '

Error al cargar partes

'; } } function toggleBulkPart(element, partId) { const checkbox = element.querySelector('input[type="checkbox"]'); if (checkbox.disabled) return; // Already linked checkbox.checked = !checkbox.checked; element.classList.toggle('selected', checkbox.checked); updateBulkSelectedCount(); } function updateBulkSelectedCount() { const checkboxes = document.querySelectorAll('#bulkPartsContainer input[type="checkbox"]:checked:not(:disabled)'); document.getElementById('bulkSelectedCount').textContent = `${checkboxes.length} partes seleccionadas`; } async function saveBulkFitments() { if (!bulkSelectedMYEId) { showAlert('Selecciona un vehículo primero', 'error'); return; } const selectedItems = document.querySelectorAll('#bulkPartsContainer .bulk-part-item'); const fitments = []; selectedItems.forEach(item => { const checkbox = item.querySelector('input[type="checkbox"]'); if (checkbox.checked && !checkbox.disabled) { const partId = parseInt(checkbox.dataset.partId); const quantity = parseInt(item.querySelector('.bulk-part-qty').value) || 1; fitments.push({ model_year_engine_id: bulkSelectedMYEId, part_id: partId, quantity_required: quantity }); } }); if (fitments.length === 0) { showAlert('Selecciona al menos una parte', 'error'); return; } try { const response = await fetch('/api/admin/import/fitment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ records: fitments }) }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Error al guardar'); } showAlert(`${result.imported} fitments creados exitosamente`); await loadBulkParts(); // Refresh to show newly linked parts loadFitment(); // Refresh fitment table loadDashboard(); // Refresh stats } catch (e) { showAlert(e.message, 'error'); } } // Initialize bulk editor when fitment section is shown const originalShowSection = showSection; showSection = function(sectionId) { originalShowSection(sectionId); if (sectionId === 'fitment') { initBulkEditor(); } }; // ============================================================================ // Diagram Hotspot Editor // ============================================================================ let currentEditorDiagramId = null; let currentEditorHotspots = []; let partSearchTimeout = null; async function searchDiagramsAdmin() { const q = document.getElementById('diagramSearchInput').value.trim(); const container = document.getElementById('diagramSearchResults'); if (!q) { container.innerHTML = '

Ingresa un código de diagrama para buscar

'; return; } container.innerHTML = '

Buscando...

'; try { const res = await fetch(`/api/diagrams/search?q=${encodeURIComponent(q)}`); const diagrams = await res.json(); if (diagrams.length === 0) { container.innerHTML = '

No se encontraron diagramas

'; return; } container.innerHTML = diagrams.map(d => { const imgSrc = d.image_path ? '/' + d.image_path : `/static/diagrams/moog/${d.name}.jpg`; return `
${d.name}
${d.name}
${d.name_es || d.source || ''}
`; }).join(''); } catch (e) { container.innerHTML = '

Error al buscar diagramas

'; } } async function openHotspotEditor(diagramId) { currentEditorDiagramId = diagramId; document.getElementById('hotspotEditorArea').style.display = 'block'; try { const res = await fetch(`/api/diagrams/${diagramId}`); const diagram = await res.json(); document.getElementById('hotspotEditorTitle').textContent = `${diagram.name} - ${diagram.name_es || diagram.group_name || ''}`; const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : ''); document.getElementById('hotspotEditorImg').src = imgSrc; currentEditorHotspots = diagram.hotspots || []; renderEditorHotspots(); clearHotspotForm(); // Auto-set next callout number const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0); document.getElementById('hsCallout').value = maxCallout + 1; // Scroll to editor document.getElementById('hotspotEditorArea').scrollIntoView({ behavior: 'smooth' }); } catch (e) { showAlert('Error al cargar diagrama', 'error'); } } function closeHotspotEditor() { document.getElementById('hotspotEditorArea').style.display = 'none'; currentEditorDiagramId = null; currentEditorHotspots = []; } function onHotspotImageClick(event) { const img = event.target; const rect = img.getBoundingClientRect(); const xPct = ((event.clientX - rect.left) / rect.width * 100).toFixed(2); const yPct = ((event.clientY - rect.top) / rect.height * 100).toFixed(2); document.getElementById('hsCoords').value = `${xPct},${yPct}`; // Show temporary marker renderEditorHotspots(); const container = document.getElementById('hotspotMarkersContainer'); const tempMarker = document.createElement('div'); tempMarker.style.cssText = `position:absolute;left:${xPct}%;top:${yPct}%;width:24px;height:24px;border-radius:50%;background:rgba(46,204,113,0.5);border:2px solid #2ecc71;transform:translate(-50%,-50%);pointer-events:none;z-index:10`; container.appendChild(tempMarker); } function renderEditorHotspots() { const container = document.getElementById('hotspotMarkersContainer'); const list = document.getElementById('hotspotsList'); // Markers on image container.innerHTML = currentEditorHotspots.map(h => { const coords = (h.coords || '').split(','); if (coords.length < 2) return ''; return `
${h.callout_number || ''}
`; }).join(''); // List if (currentEditorHotspots.length === 0) { list.innerHTML = '

No hay hotspots

'; return; } list.innerHTML = currentEditorHotspots.map(h => `
${h.callout_number || '?'}
${h.part_name || h.label || 'Sin parte'}
${h.part_number || ''} | ${h.coords}
`).join(''); } function editHotspot(hotspotId) { const hs = currentEditorHotspots.find(h => h.id === hotspotId); if (!hs) return; document.getElementById('hsEditId').value = hs.id; document.getElementById('hsCoords').value = hs.coords || ''; document.getElementById('hsCallout').value = hs.callout_number || ''; document.getElementById('hsLabel').value = hs.label || ''; document.getElementById('hsPartId').value = hs.part_id || ''; document.getElementById('hsPartSearch').value = hs.part_name ? `${hs.part_number} - ${hs.part_name}` : ''; } function clearHotspotForm() { document.getElementById('hsEditId').value = ''; document.getElementById('hsCoords').value = ''; document.getElementById('hsLabel').value = ''; document.getElementById('hsPartId').value = ''; document.getElementById('hsPartSearch').value = ''; document.getElementById('hsPartSelect').style.display = 'none'; // Keep callout at next number const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0); document.getElementById('hsCallout').value = maxCallout + 1; } async function searchPartsForHotspot(query) { clearTimeout(partSearchTimeout); const select = document.getElementById('hsPartSelect'); if (!query || query.length < 2) { select.style.display = 'none'; return; } partSearchTimeout = setTimeout(async () => { try { const res = await fetch(`/api/parts?search=${encodeURIComponent(query)}&per_page=20`); const data = await res.json(); const parts = data.data || data; if (parts.length === 0) { select.innerHTML = ''; } else { select.innerHTML = parts.map(p => `` ).join(''); } select.style.display = 'block'; select.onchange = function() { const opt = select.options[select.selectedIndex]; document.getElementById('hsPartId').value = opt.value; document.getElementById('hsPartSearch').value = opt.textContent; select.style.display = 'none'; }; } catch (e) { select.innerHTML = ''; select.style.display = 'block'; } }, 300); } async function saveHotspot() { const editId = document.getElementById('hsEditId').value; const coords = document.getElementById('hsCoords').value.trim(); const callout = parseInt(document.getElementById('hsCallout').value) || null; const partId = parseInt(document.getElementById('hsPartId').value) || null; const label = document.getElementById('hsLabel').value.trim(); if (!coords) { showAlert('Haz clic en la imagen para seleccionar posición', 'error'); return; } const body = { diagram_id: currentEditorDiagramId, coords: coords, callout_number: callout, part_id: partId, label: label, shape: 'circle', color: '#e74c3c' }; try { let res; if (editId) { res = await fetch(`/api/admin/hotspots/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } else { res = await fetch('/api/admin/hotspots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } const result = await res.json(); if (!res.ok) throw new Error(result.error || 'Error al guardar'); showAlert(editId ? 'Hotspot actualizado' : 'Hotspot creado'); // Reload diagram to refresh hotspots await openHotspotEditor(currentEditorDiagramId); } catch (e) { showAlert(e.message, 'error'); } } async function deleteHotspot(hotspotId) { if (!confirm('Eliminar este hotspot?')) return; try { const res = await fetch(`/api/admin/hotspots/${hotspotId}`, { method: 'DELETE' }); const result = await res.json(); if (!res.ok) throw new Error(result.error || 'Error al eliminar'); showAlert('Hotspot eliminado'); await openHotspotEditor(currentEditorDiagramId); } catch (e) { showAlert(e.message, 'error'); } }