fix: performance improvements, shared UI, and cross-reference data quality
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>
This commit is contained in:
@@ -115,6 +115,9 @@ function showSection(sectionId) {
|
||||
case 'fitment':
|
||||
loadFitment();
|
||||
break;
|
||||
case 'diagrams':
|
||||
// Just show section, user uses search
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1175,11 +1178,12 @@ async function loadVehiclesForSelect(selectId) {
|
||||
if (!select) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/model-year-engine');
|
||||
const vehicles = await response.json();
|
||||
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.slice(0, 100).map(v =>
|
||||
vehicles.map(v =>
|
||||
`<option value="${v.id}">${v.brand} ${v.model} ${v.year} - ${v.engine}</option>`
|
||||
).join('');
|
||||
} catch (e) {
|
||||
@@ -1558,8 +1562,9 @@ async function loadBulkEngines() {
|
||||
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}`);
|
||||
const myeData = await myeResponse.json();
|
||||
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('');
|
||||
@@ -1707,3 +1712,258 @@ showSection = function(sectionId) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user