Backend (server.py): - Fix N+1 query in /api/diagrams/<id>/parts with batch cross-ref query - Add LIMIT safety nets to 15 endpoints (50-5000 per data type) - Add pagination to /api/vehicles, /api/model-year-engine, /api/vehicles/<id>/parts, /api/admin/export - Optimize search_vehicles() EXISTS subquery to JOIN - Restrict static route to /static/* subdir (security fix) - Add detailed=true support to /api/brands and /api/models Frontend: - Extract shared CSS into shared.css (variables, reset, buttons, forms, scrollbar) - Create shared nav.js component (logo + navigation links, auto-highlights) - Update all 4 HTML pages to use shared CSS and nav - Update JS to handle paginated API responses Data quality: - Fix cross-reference source field: map 72K records from catalog names to actual brands - Fix aftermarket_parts manufacturer_id: correct 8K records with wrong brand attribution - Delete 98MB backup file, orphan records, and garbage cross-references - Add import scripts for DAR, FRAM, WIX, MOOG, Cartek catalogs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1970 lines
73 KiB
JavaScript
1970 lines
73 KiB
JavaScript
/**
|
|
* Admin Panel JavaScript
|
|
* CRUD operations and CSV import/export for Autopartes DB
|
|
*/
|
|
|
|
// 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 = `<span>${type === 'success' ? '✓' : '✕'}</span> ${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 = '<tr><td colspan="7" style="text-align: center; color: var(--text-secondary);">No hay categorías</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = categoriesCache.map(cat => `
|
|
<tr>
|
|
<td>${cat.id}</td>
|
|
<td>${cat.name}</td>
|
|
<td>${cat.name_es || '-'}</td>
|
|
<td>${cat.slug || '-'}</td>
|
|
<td>${cat.icon_name || '-'}</td>
|
|
<td>${cat.display_order || 0}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-secondary" onclick="editCategory(${cat.id})">Editar</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteCategory(${cat.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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') ? '<option value="">Todas las categorías</option>' : '<option value="">Selecciona categoría...</option>';
|
|
|
|
select.innerHTML = defaultOption + categoriesCache.map(cat =>
|
|
`<option value="${cat.id}">${'—'.repeat(cat.level || 0)} ${cat.name}</option>`
|
|
).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 = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">No hay grupos</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = groups.map(group => `
|
|
<tr>
|
|
<td>${group.id}</td>
|
|
<td>${group.name}</td>
|
|
<td>${group.name_es || '-'}</td>
|
|
<td>${group.category_name || '-'}</td>
|
|
<td>${group.display_order || 0}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-secondary" onclick="editGroup(${group.id})">Editar</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteGroup(${group.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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') ? '<option value="">Todos los grupos</option>' : '<option value="">Selecciona grupo...</option>';
|
|
|
|
select.innerHTML = defaultOption + groupsCache.map(g =>
|
|
`<option value="${g.id}">${g.name} (${g.category_name || 'Sin categoría'})</option>`
|
|
).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 = '<option value="">Selecciona categoría...</option>' +
|
|
categoriesCache.map(cat => `<option value="${cat.id}">${cat.name}</option>`).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 = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">No hay partes</td></tr>';
|
|
renderPagination('partsPagination', result.pagination, 'parts', loadParts);
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = partsCache.map(part => `
|
|
<tr>
|
|
<td>${part.id}</td>
|
|
<td>
|
|
${part.image_url
|
|
? `<img src="${part.image_url}" alt="${part.name}" class="part-thumbnail">`
|
|
: '<span style="color: var(--text-secondary);">📷</span>'}
|
|
</td>
|
|
<td><code>${part.oem_part_number}</code></td>
|
|
<td>${part.name}</td>
|
|
<td>${part.group_name || '-'}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-secondary" onclick="editPart(${part.id})">Editar</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deletePart(${part.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<option value="">Selecciona grupo...</option>' +
|
|
groupsCache.map(g => `<option value="${g.id}">${g.name}</option>`).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 = `<img src="${part.image_url}" alt="${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 = `<img src="${e.target.result}" alt="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 = '<span class="image-placeholder">📷 Sin imagen</span>';
|
|
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 = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">No hay fabricantes</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = manufacturersCache.map(m => `
|
|
<tr>
|
|
<td>${m.id}</td>
|
|
<td>${m.name}</td>
|
|
<td>${m.type || '-'}</td>
|
|
<td><span class="badge badge-${m.quality_tier || 'standard'}">${m.quality_tier || '-'}</span></td>
|
|
<td>${m.country || '-'}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-secondary" onclick="editManufacturer(${m.id})">Editar</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteManufacturer(${m.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<option value="">Todos los fabricantes</option>' +
|
|
manufacturersCache.map(m => `<option value="${m.id}">${m.name}</option>`).join('');
|
|
select.value = currentValue;
|
|
}
|
|
|
|
const modalSelect = document.getElementById('aftermarketManufacturer');
|
|
if (modalSelect) {
|
|
modalSelect.innerHTML = '<option value="">Selecciona fabricante...</option>' +
|
|
manufacturersCache.map(m => `<option value="${m.id}">${m.name}</option>`).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 = '<tr><td colspan="8" style="text-align: center; color: var(--text-secondary);">No hay partes aftermarket</td></tr>';
|
|
renderPagination('aftermarketPagination', result.pagination, 'aftermarket', loadAftermarket);
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = result.data.map(part => `
|
|
<tr>
|
|
<td>${part.id}</td>
|
|
<td><code>${part.part_number}</code></td>
|
|
<td>${part.name || '-'}</td>
|
|
<td><code>${part.oem_part_number}</code></td>
|
|
<td>${part.manufacturer_name}</td>
|
|
<td><span class="badge badge-${part.quality_tier || 'standard'}">${part.quality_tier || '-'}</span></td>
|
|
<td>${part.price_usd ? '$' + part.price_usd.toFixed(2) : '-'}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-secondary" onclick="editAftermarket(${part.id})">Editar</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteAftermarket(${part.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">No hay cross-references</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = result.data.map(ref => `
|
|
<tr>
|
|
<td>${ref.id}</td>
|
|
<td><code>${ref.oem_part_number}</code> - ${ref.part_name}</td>
|
|
<td><code>${ref.cross_reference_number}</code></td>
|
|
<td>${ref.reference_type}</td>
|
|
<td>${ref.source || '-'}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-secondary" onclick="editCrossRef(${ref.id})">Editar</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteCrossRef(${ref.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<tr><td colspan="6" style="text-align: center; color: var(--text-secondary);">No hay fitments</td></tr>';
|
|
renderPagination('fitmentPagination', result.pagination, 'fitment', loadFitment);
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = result.data.map(f => `
|
|
<tr>
|
|
<td>${f.id}</td>
|
|
<td>${f.brand} ${f.model} ${f.year} - ${f.engine}</td>
|
|
<td><code>${f.oem_part_number}</code> - ${f.part_name}</td>
|
|
<td>${f.quantity_required}</td>
|
|
<td>${f.position || '-'}</td>
|
|
<td class="actions-cell">
|
|
<button class="btn btn-sm btn-danger" onclick="deleteFitment(${f.id})">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<option value="">Todos los modelos</option>';
|
|
loadFitment();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/models?brand=${encodeURIComponent(brand)}`);
|
|
const models = await response.json();
|
|
|
|
modelSelect.innerHTML = '<option value="">Todos los modelos</option>' +
|
|
models.map(m => `<option value="${m}">${m}</option>`).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 = '<option value="">Selecciona parte...</option>' +
|
|
(result.data || []).map(p =>
|
|
`<option value="${p.id}">${p.oem_part_number} - ${p.name}</option>`
|
|
).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 = '<option value="">Selecciona vehículo...</option>' +
|
|
vehicles.map(v =>
|
|
`<option value="${v.id}">${v.brand} ${v.model} ${v.year} - ${v.engine}</option>`
|
|
).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 = '<option value="">Selecciona marca...</option>' +
|
|
brandsCache.map(b => `<option value="${b}">${b}</option>`).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 += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, ${loadFunction.name})">← Anterior</button>`;
|
|
|
|
// 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 += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, ${loadFunction.name})">${i}</button>`;
|
|
}
|
|
|
|
// Next button
|
|
html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, ${loadFunction.name})">Siguiente →</button>`;
|
|
|
|
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 = `
|
|
<p style="margin-bottom: 0.5rem;"><strong>Columnas requeridas:</strong> ${format.required.join(', ')}</p>
|
|
<p style="margin-bottom: 0.5rem;"><strong>Todas las columnas:</strong> ${format.columns.join(', ')}</p>
|
|
<p style="margin-bottom: 0.5rem;"><strong>Ejemplo:</strong></p>
|
|
<pre style="background: var(--bg-tertiary); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem;">${format.example}</pre>
|
|
`;
|
|
}
|
|
|
|
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 = '<tr>' +
|
|
headers.map(h => `<th>${h}</th>`).join('') + '</tr>';
|
|
|
|
document.getElementById('previewBody').innerHTML = data.map(row =>
|
|
'<tr>' + row.map(cell => `<td>${cell}</td>`).join('') + '</tr>'
|
|
).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 = '<option value="">Selecciona marca...</option>' +
|
|
brandsCache.map(b => `<option value="${b}">${b}</option>`).join('');
|
|
}
|
|
|
|
// Populate categories for filter
|
|
if (categoriesCache.length === 0) {
|
|
await loadCategories();
|
|
}
|
|
const bulkCategory = document.getElementById('bulkCategory');
|
|
if (bulkCategory) {
|
|
bulkCategory.innerHTML = '<option value="">Todas las categorías</option>' +
|
|
categoriesCache.map(c => `<option value="${c.id}">${c.name}</option>`).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 = '<option value="">Selecciona modelo...</option>';
|
|
yearSelect.innerHTML = '<option value="">Selecciona año...</option>';
|
|
engineSelect.innerHTML = '<option value="">Selecciona motor...</option>';
|
|
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 = '<option value="">Selecciona modelo...</option>' +
|
|
models.map(m => `<option value="${m}">${m}</option>`).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 = '<option value="">Selecciona año...</option>';
|
|
engineSelect.innerHTML = '<option value="">Selecciona motor...</option>';
|
|
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 = '<option value="">Selecciona año...</option>' +
|
|
years.map(y => `<option value="${y}">${y}</option>`).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 = '<option value="">Selecciona motor...</option>';
|
|
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 = '<option value="">Selecciona motor...</option>' +
|
|
myeData.map(mye => `<option value="${mye.id}">${mye.engine}</option>`).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 = '<p style="color: var(--text-secondary);">Cargando partes...</p>';
|
|
|
|
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 = '<p style="color: var(--text-secondary);">No hay partes disponibles en esta categoría.</p>';
|
|
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 => `
|
|
<div class="bulk-part-item ${existingPartIds.has(part.id) ? 'selected' : ''}" onclick="toggleBulkPart(this, ${part.id})">
|
|
<input type="checkbox" ${existingPartIds.has(part.id) ? 'checked disabled' : ''} data-part-id="${part.id}">
|
|
<div class="bulk-part-info">
|
|
<div class="bulk-part-number">${part.oem_part_number}</div>
|
|
<div class="bulk-part-name">${part.name}</div>
|
|
<div class="bulk-part-group">${part.group_name || ''} · ${part.category_name || ''}</div>
|
|
</div>
|
|
<input type="number" class="form-input bulk-part-qty" value="1" min="1" placeholder="Cant."
|
|
onclick="event.stopPropagation()" ${existingPartIds.has(part.id) ? 'disabled' : ''}>
|
|
</div>
|
|
`).join('');
|
|
|
|
updateBulkSelectedCount();
|
|
} catch (e) {
|
|
console.error('Error loading parts:', e);
|
|
container.innerHTML = '<p style="color: var(--danger);">Error al cargar partes</p>';
|
|
}
|
|
}
|
|
|
|
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 = '<p style="color:var(--text-secondary);grid-column:1/-1">Ingresa un código de diagrama para buscar</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1"><i class="fas fa-spinner fa-spin"></i> Buscando...</p>';
|
|
|
|
try {
|
|
const res = await fetch(`/api/diagrams/search?q=${encodeURIComponent(q)}`);
|
|
const diagrams = await res.json();
|
|
|
|
if (diagrams.length === 0) {
|
|
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1">No se encontraron diagramas</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = diagrams.map(d => {
|
|
const imgSrc = d.image_path ? '/' + d.image_path : `/static/diagrams/moog/${d.name}.jpg`;
|
|
return `
|
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;overflow:hidden;cursor:pointer;transition:border-color 0.2s"
|
|
onclick="openHotspotEditor(${d.id})"
|
|
onmouseover="this.style.borderColor='var(--accent)'"
|
|
onmouseout="this.style.borderColor='var(--border)'">
|
|
<img src="${imgSrc}" alt="${d.name}" style="width:100%;height:120px;object-fit:contain;background:#f0f0f0;display:block"
|
|
onerror="this.style.display='none'">
|
|
<div style="padding:0.5rem 0.65rem">
|
|
<div style="font-weight:600;color:var(--accent)">${d.name}</div>
|
|
<div style="font-size:0.8rem;color:var(--text-secondary)">${d.name_es || d.source || ''}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
container.innerHTML = '<p style="color:#e74c3c;grid-column:1/-1">Error al buscar diagramas</p>';
|
|
}
|
|
}
|
|
|
|
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 `<div style="position:absolute;left:${coords[0]}%;top:${coords[1]}%;width:24px;height:24px;border-radius:50%;background:rgba(231,76,60,0.4);border:2px solid #e74c3c;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;font-size:0.6rem;font-weight:700;color:white;pointer-events:auto;cursor:pointer" onclick="editHotspot(${h.id})" title="${h.label || h.part_name || ''}">${h.callout_number || ''}</div>`;
|
|
}).join('');
|
|
|
|
// List
|
|
if (currentEditorHotspots.length === 0) {
|
|
list.innerHTML = '<p style="color:var(--text-secondary);font-size:0.85rem">No hay hotspots</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = currentEditorHotspots.map(h => `
|
|
<div style="background:var(--bg-hover);border:1px solid var(--border);border-radius:6px;padding:0.5rem;margin-bottom:0.4rem;display:flex;align-items:center;gap:0.5rem">
|
|
<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">${h.callout_number || '?'}</span>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:0.82rem;font-weight:500">${h.part_name || h.label || 'Sin parte'}</div>
|
|
<div style="font-size:0.72rem;color:var(--text-secondary)">${h.part_number || ''} | ${h.coords}</div>
|
|
</div>
|
|
<button class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem" onclick="editHotspot(${h.id})">Editar</button>
|
|
<button class="btn" style="padding:0.2rem 0.5rem;font-size:0.75rem;background:#e74c3c;color:white;border:none;border-radius:4px;cursor:pointer" onclick="deleteHotspot(${h.id})">Borrar</button>
|
|
</div>
|
|
`).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 = '<option disabled>Sin resultados</option>';
|
|
} else {
|
|
select.innerHTML = parts.map(p =>
|
|
`<option value="${p.id}">${p.oem_part_number} - ${p.name_es || p.name}</option>`
|
|
).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 = '<option disabled>Error buscando</option>';
|
|
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');
|
|
}
|
|
}
|