({
parte_id: parte.id,
codigo: r.codigo,
nombre: r.nombre,
precio: r.precio,
cantidad: r.cantidad || 1,
}))
);
}
// 3. Mano de obra
if (parteData.manoObra?.length) {
await db.from('parte_mano_obra').insert(
parteData.manoObra.map(m => ({
parte_id: parte.id,
tipo: m.tipo,
horas: m.horas,
precio: m.precio,
}))
);
}
// 4. Subir firma si existe
if (parteData.firma) {
const firmaUrl = await subirFirma(parte.id, parteData.firma);
await db.from('partes').update({ firma_url: firmaUrl }).eq('id', parte.id);
}
// 5. Subir fotos
if (parteData.fotosCliente?.length) {
await subirFotos(parte.id, null, parteData.fotosCliente, 'cliente');
}
return parte.id;
},
async actualizar(id, parteData) {
// Actualizar parte
const { error } = await db.from('partes').update({
cliente_cod: parteData.clienteCod,
cliente_nombre: parteData.clienteNombre,
equipo_nombre: parteData.equipoNombre,
tecnico_nombre: parteData.tecnico,
fecha: parteData.fecha,
tipo: parteData.tipo,
problema: parteData.problema,
trabajos: parteData.trabajos,
desplazamiento: parteData.desplazamiento || 0,
km: parteData.km || 0,
obs: parteData.obs,
estado: parteData.estado,
firma_geo: parteData.firmaGeo || null,
}).eq('id', id);
if (error) throw error;
// Reemplazar repuestos y mano de obra
await db.from('parte_repuestos').delete().eq('parte_id', id);
await db.from('parte_mano_obra').delete().eq('parte_id', id);
if (parteData.repuestos?.length) {
await db.from('parte_repuestos').insert(
parteData.repuestos.map(r => ({ parte_id: id, codigo: r.codigo, nombre: r.nombre, precio: r.precio, cantidad: r.cantidad || 1 }))
);
}
if (parteData.manoObra?.length) {
await db.from('parte_mano_obra').insert(
parteData.manoObra.map(m => ({ parte_id: id, tipo: m.tipo, horas: m.horas, precio: m.precio }))
);
}
// Actualizar firma si cambió
if (parteData.firma && parteData.firma.startsWith('data:')) {
const firmaUrl = await subirFirma(id, parteData.firma);
await db.from('partes').update({ firma_url: firmaUrl }).eq('id', id);
}
},
async eliminar(id) {
await db.from('partes').delete().eq('id', id);
},
async cambiarEstado(id, estado) {
await db.from('partes').update({ estado }).eq('id', id);
}
};
// ── AVISOS ───────────────────────────────────────────────────────
const SupaAvisos = {
async getAll() {
const { data, error } = await db.from('avisos')
.select(`*, fotos(*)`)
.order('numero', { ascending: false });
if (error) throw error;
return data.map(mapAvisoDeBD);
},
async crear(avisoData) {
const { data, error } = await db.from('avisos').insert({
cliente_cod: avisoData.clienteCod,
cliente_nombre: avisoData.clienteNombre,
equipo_id: avisoData.equipoId || null,
equipo_nombre: avisoData.equipoNombre,
tecnico_id: currentUsuario?.id || null,
tecnico_nombre: avisoData.tecnico,
fecha: avisoData.fecha,
problema: avisoData.problema,
estado: avisoData.estado || 'pendiente',
}).select().single();
if (error) throw error;
// Subir fotos del aviso
if (avisoData.fotosCliente?.length) {
await subirFotos(null, data.id, avisoData.fotosCliente, 'cliente');
}
return data.id;
},
async actualizar(id, avisoData) {
const { error } = await db.from('avisos').update({
cliente_cod: avisoData.clienteCod,
cliente_nombre: avisoData.clienteNombre,
equipo_nombre: avisoData.equipoNombre,
tecnico_nombre: avisoData.tecnico,
fecha: avisoData.fecha,
problema: avisoData.problema,
estado: avisoData.estado,
}).eq('id', id);
if (error) throw error;
},
async eliminar(id) {
await db.from('avisos').delete().eq('id', id);
},
async vincularParte(avisoId, parteId, nuevoEstado) {
await db.from('avisos').update({
parte_id: parteId,
estado: nuevoEstado
}).eq('id', avisoId);
}
};
// ── EQUIPOS ──────────────────────────────────────────────────────
const SupaEquipos = {
async getPorCliente(clienteCod) {
const { data } = await db.from('equipos')
.select('*').eq('cliente_cod', clienteCod).eq('activo', true);
return data || [];
},
async crear(clienteCod, eq) {
let fotoUrl = null;
if (eq.foto && eq.foto.startsWith('data:')) {
fotoUrl = await subirFotoEquipo(eq.foto);
}
const { data, error } = await db.from('equipos').insert({
cliente_cod: clienteCod,
nombre: eq.nombre,
marca: eq.marca,
modelo: eq.modelo,
serie: eq.serie,
anio: eq.año ? parseInt(eq.año) : null,
ubicacion: eq.ubic,
obs: eq.obs,
foto_url: fotoUrl,
}).select().single();
if (error) throw error;
return data;
},
async eliminar(id) {
await db.from('equipos').update({ activo: false }).eq('id', id);
}
};
// ── USUARIOS ─────────────────────────────────────────────────────
const SupaUsuarios = {
async getAll() {
const { data } = await db.from('usuarios').select('*').eq('activo', true);
return data || [];
},
async crear(email, password, nombre, rol = 'tecnico') {
// 1. Crear en Supabase Auth
const { data: authData, error: authErr } = await db.auth.admin.createUser({
email, password, email_confirm: true
});
if (authErr) throw authErr;
// 2. Insertar en nuestra tabla
const { data, error } = await db.from('usuarios').insert({
auth_id: authData.user.id,
nombre, email, rol
}).select().single();
if (error) throw error;
return data;
}
};
// ── CONFIG ───────────────────────────────────────────────────────
const SupaConfig = {
async getAll() {
const { data } = await db.from('config').select('*');
const cfg = {};
(data || []).forEach(r => { cfg[r.clave] = r.valor; });
return cfg;
},
async set(clave, valor) {
await db.from('config').upsert({ clave, valor });
localStorage.setItem(clave, valor); // cache local
}
};
// ── STORAGE: subir archivos ───────────────────────────────────────
async function subirFirma(parteId, dataUrl) {
const blob = dataURLtoBlob(dataUrl);
const path = `firmas/${parteId}.png`;
const { error } = await db.storage.from('firmas').upload(path, blob, {
contentType: 'image/png', upsert: true
});
if (error) throw error;
const { data } = db.storage.from('firmas').getPublicUrl(path);
return data.publicUrl;
}
async function subirFotos(parteId, avisoId, fotos, tipo) {
for (const foto of fotos) {
if (!foto.url || !foto.url.startsWith('data:')) continue;
const blob = dataURLtoBlob(foto.url);
const ext = foto.name?.split('.').pop() || 'jpg';
const id = parteId || avisoId;
const path = `fotos/${tipo}/${id}/${Date.now()}.${ext}`;
const { error } = await db.storage.from('fotos').upload(path, blob, {
contentType: blob.type, upsert: false
});
if (error) { console.warn('Error subiendo foto:', error); continue; }
const { data } = db.storage.from('fotos').getPublicUrl(path);
// Registrar en tabla fotos
await db.from('fotos').insert({
parte_id: parteId || null,
aviso_id: avisoId || null,
tipo, url: data.publicUrl, nombre: foto.name
});
}
}
async function subirFotoEquipo(dataUrl) {
const blob = dataURLtoBlob(dataUrl);
const path = `equipos/${Date.now()}.jpg`;
const { error } = await db.storage.from('equipos').upload(path, blob, {
contentType: 'image/jpeg', upsert: false
});
if (error) return null;
const { data } = db.storage.from('equipos').getPublicUrl(path);
return data.publicUrl;
}
// ── HELPERS ──────────────────────────────────────────────────────
function dataURLtoBlob(dataUrl) {
const [header, data] = dataUrl.split(',');
const mime = header.match(/:(.*?);/)[1];
const binary = atob(data);
const arr = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
return new Blob([arr], { type: mime });
}
// Convertir formato BD → formato app
function mapParteDeBD(p) {
return {
id: p.id,
numero: p.numero,
avisoId: p.aviso_id,
clienteCod: p.cliente_cod,
clienteNombre: p.cliente_nombre,
equipoNombre: p.equipo_nombre,
equipoId: p.equipo_id,
tecnico: p.tecnico_nombre,
fecha: p.fecha,
tipo: p.tipo,
problema: p.problema,
trabajos: p.trabajos,
repuestos: (p.parte_repuestos || []).map(r => ({
codigo: r.codigo, nombre: r.nombre,
precio: r.precio, cantidad: r.cantidad
})),
manoObra: (p.parte_mano_obra || []).map(m => ({
tipo: m.tipo, horas: m.horas, precio: m.precio
})),
desplazamiento: p.desplazamiento,
km: p.km,
obs: p.obs,
estado: p.estado,
firma: p.firma_url,
firmaGeo: p.firma_geo,
fotosCliente: (p.fotos || []).filter(f => f.tipo === 'cliente').map(f => ({ url: f.url, name: f.nombre, type: 'img' })),
fotosReparacion:(p.fotos || []).filter(f => f.tipo === 'reparacion').map(f => ({ url: f.url, name: f.nombre, type: 'img' })),
};
}
function mapAvisoDeBD(a) {
return {
id: a.id,
numero: a.numero,
clienteCod: a.cliente_cod,
clienteNombre: a.cliente_nombre,
equipoNombre: a.equipo_nombre,
equipoId: a.equipo_id,
tecnico: a.tecnico_nombre,
fecha: a.fecha,
problema: a.problema,
estado: a.estado,
parteId: a.parte_id,
fotosCliente: (a.fotos || []).map(f => ({ url: f.url, name: f.nombre, type: 'img' })),
};
}
// ── SINCRONIZACIÓN OFFLINE ───────────────────────────────────────
const Sync = {
PENDING_KEY: 'frimay_sync_pending',
// Guardar operación pendiente cuando no hay conexión
async guardarPendiente(tabla, accion, datos) {
const pending = JSON.parse(localStorage.getItem(this.PENDING_KEY) || '[]');
pending.push({ tabla, accion, datos, ts: Date.now(), tempId: 'tmp_' + Date.now() });
localStorage.setItem(this.PENDING_KEY, JSON.stringify(pending));
},
// Sincronizar cuando vuelve la conexión
async sincronizar() {
const pending = JSON.parse(localStorage.getItem(this.PENDING_KEY) || '[]');
if (!pending.length) return;
console.log(`Sincronizando ${pending.length} operaciones pendientes...`);
const fallidas = [];
for (const op of pending) {
try {
if (op.tabla === 'partes') {
if (op.accion === 'insert') await SupaPartes.crear(op.datos);
if (op.accion === 'update') await SupaPartes.actualizar(op.datos.id, op.datos);
if (op.accion === 'delete') await SupaPartes.eliminar(op.datos.id);
}
if (op.tabla === 'avisos') {
if (op.accion === 'insert') await SupaAvisos.crear(op.datos);
if (op.accion === 'update') await SupaAvisos.actualizar(op.datos.id, op.datos);
}
} catch (e) {
console.warn('Error sincronizando:', op, e);
fallidas.push(op);
}
}
localStorage.setItem(this.PENDING_KEY, JSON.stringify(fallidas));
if (!fallidas.length) {
console.log('Sincronización completa ✓');
// Recargar datos frescos
await cargarDatos();
}
},
pendienteCount() {
return JSON.parse(localStorage.getItem(this.PENDING_KEY) || '[]').length;
}
};
// Detectar cambios de conexión
// ════════════════════════════════════════════════════════════════
// AUTENTICACIÓN Y ARRANQUE
// ════════════════════════════════════════════════════════════════
const SUPA_URL="https://anfsucpowljqcnfzashq.supabase.co";
const SUPA_KEY="sb_publishable_SmHp6jDIEP_L9VsEet20eA_dJCibF79";
const {createClient}=supabase;
const db=createClient(SUPA_URL,SUPA_KEY);
// Detecta enlaces de recuperación de contraseña enviados por Supabase.
db.auth.onAuthStateChange(async (event, session) => {
if (event === 'PASSWORD_RECOVERY') {
mostrarCambioPassword();
}
});
let currentUser=null;
let currentUsuario=null;
function mostrarLogin(){
document.getElementById('login-screen').style.display='flex';
document.getElementById('main-app').style.display='none';
}
function mostrarApp(){
document.getElementById('login-screen').style.display='none';
document.getElementById('main-app').style.display='';
if(currentUsuario){
const nm=document.getElementById('user-name');
const rl=document.getElementById('user-role');
if(nm) nm.textContent=currentUsuario.nombre||currentUser.email;
if(rl) rl.textContent=currentUsuario.rol==='admin'?'Administrador':'Técnico';
}
// Activar sincronización en tiempo real
iniciarRealtime();
}
async function hacerLogin(){
const email=document.getElementById('login-email').value.trim();
const pass=document.getElementById('login-pass').value;
const btn=document.getElementById('login-btn');
const err=document.getElementById('login-error');
err.style.display='none';
btn.disabled=true;
btn.textContent='Entrando...';
try{
if(!email) throw new Error('Introduce el email.');
if(!pass) throw new Error('Introduce la contraseña.');
const {data,error}=await db.auth.signInWithPassword({email,password:pass});
if(error) throw new Error(traducirAuthError(error.message));
currentUser=data.user;
const {data:perfil,error:perfilError}=await db
.from('usuarios')
.select('*')
.eq('auth_id',data.user.id)
.single();
if(perfilError){
throw new Error('Login correcto, pero no encuentro tu perfil en la tabla usuarios. Revisa que exista auth_id = '+data.user.id+'. Detalle: '+perfilError.message);
}
if(!perfil){
throw new Error('Login correcto, pero no existe usuario interno vinculado en la tabla usuarios.');
}
currentUsuario=perfil;
mostrarApp();
try{
await cargarDatos();
}catch(e){
console.error('Error cargando datos:',e);
alert('Has entrado, pero hay un error cargando datos: '+(e.message||e));
}
}catch(e){
console.error('Error login:',e);
err.textContent=e.message||'No se pudo iniciar sesión';
err.style.display='block';
btn.disabled=false;
btn.textContent='Entrar';
}
}
function traducirAuthError(msg){
const m=String(msg||'');
if(m.toLowerCase().includes('invalid login credentials')) return 'Email o contraseña incorrectos.';
if(m.toLowerCase().includes('email not confirmed')) return 'El email todavía no está confirmado en Supabase.';
return m;
}
async function resetPassword(){
const email=document.getElementById('login-email').value.trim();
const err=document.getElementById('login-error');
err.style.display='none';
try{
if(!email) throw new Error('Introduce tu email y vuelve a pulsar “¿Olvidaste tu contraseña?”.');
const {error}=await db.auth.resetPasswordForEmail(email,{
redirectTo: window.location.origin
});
if(error) throw new Error(traducirAuthError(error.message));
err.style.display='block';
err.style.background='#f0fdf4';
err.style.borderColor='#bbf7d0';
err.style.color='#15803d';
err.textContent='Te he enviado un correo para cambiar la contraseña. Revisa también spam.';
}catch(e){
err.style.display='block';
err.style.background='#fef2f2';
err.style.borderColor='#fecaca';
err.style.color='#dc2626';
err.textContent=e.message||'No se pudo enviar el correo de recuperación.';
}
}
function mostrarCambioPassword(){
const login=document.getElementById('login-screen');
document.getElementById('main-app').style.display='none';
login.style.display='flex';
login.innerHTML=`
🔐
Nueva contraseña
Introduce y confirma tu nueva contraseña
`;
}
async function guardarNuevaPassword(){
const p1=document.getElementById('new-pass').value;
const p2=document.getElementById('new-pass-2').value;
const msg=document.getElementById('pass-msg');
msg.style.display='none';
try{
if(!p1 || p1.length<6) throw new Error('La contraseña debe tener al menos 6 caracteres.');
if(p1!==p2) throw new Error('Las contraseñas no coinciden.');
const {error}=await db.auth.updateUser({password:p1});
if(error) throw new Error(traducirAuthError(error.message));
msg.style.display='block';
msg.style.background='#f0fdf4';
msg.style.borderColor='#bbf7d0';
msg.style.color='#15803d';
msg.textContent='Contraseña actualizada correctamente. Ya puedes entrar.';
await db.auth.signOut();
setTimeout(()=>{ window.location.href=window.location.origin; },1500);
}catch(e){
msg.style.display='block';
msg.style.background='#fef2f2';
msg.style.borderColor='#fecaca';
msg.style.color='#dc2626';
msg.textContent=e.message||'No se pudo cambiar la contraseña.';
}
}
async function logout(){
pararRealtime();
await db.auth.signOut();
currentUser=null; currentUsuario=null;
mostrarLogin();
}
// Soporte Enter en login
document.addEventListener('DOMContentLoaded',function(){
document.getElementById('login-pass')?.addEventListener('keydown',function(e){
if(e.key==='Enter') hacerLogin();
});
});
// Arranque: verificar sesión existente
(async function arranque(){
const esRecovery = window.location.hash.includes('type=recovery') || window.location.search.includes('type=recovery');
if(esRecovery){
// Supabase terminará de crear la sesión y lanzará PASSWORD_RECOVERY.
setTimeout(()=>mostrarCambioPassword(),500);
return;
}
const {data:{session}}=await db.auth.getSession();
if(session){
try{
currentUser=session.user;
const {data:perfil,error:perfilError}=await db
.from('usuarios')
.select('*')
.eq('auth_id',session.user.id)
.single();
if(perfilError) throw perfilError;
currentUsuario=perfil;
mostrarApp();
await cargarDatos();
}catch(e){
console.error('Sesión existente, pero error cargando perfil/datos:',e);
await db.auth.signOut();
mostrarLogin();
const err=document.getElementById('login-error');
if(err){
err.textContent='Había sesión iniciada, pero no se pudo cargar tu perfil. Revisa tabla usuarios/RLS. '+(e.message||'');
err.style.display='block';
}
}
} else {
mostrarLogin();
}
})();
// Detectar entorno: Cloudflare usa proxy, local usa Railway directamente
const RAILWAY_URL = 'https://frimaypartesdetrabajo-production.up.railway.app';
const ES_CLOUDFLARE = window.location.hostname.includes('pages.dev') ||
window.location.hostname.includes('frimaypartesdetrabajo');
const API = ES_CLOUDFLARE ? '' : RAILWAY_URL;
console.log('API modo:', ES_CLOUDFLARE ? 'Cloudflare proxy' : 'Railway directo', '→', API||'/api/*');
let clientes=[],articulos=[];
// Datos en memoria (cargados desde Supabase)
let partes=[];
let equipos={};
let currentPage='dashboard',apiOk=false;
let parteNum=partes.length>0?Math.max(...partes.map(p=>parseInt(p.numero)||0)):0;
let _cliBusq='',_artBusq='',_cliSel=null;
// ── INDEXEDDB PARA FOTOS ────────────────────────────────────────
let _idb = null;
function abrirIDB(){
return new Promise((res, rej)=>{
if(_idb){ res(_idb); return; }
const req = indexedDB.open('frimay_fotos', 1);
req.onupgradeneeded = e => {
e.target.result.createObjectStore('fotos');
};
req.onsuccess = e => { _idb = e.target.result; res(_idb); };
req.onerror = () => rej(req.error);
});
}
async function guardarFotosIDB(parteId, fotos){
console.log('guardarFotosIDB:', parteId, fotos);
if(!fotos || (!fotos.fotosCliente?.length && !fotos.fotosAviso?.length && !fotos.fotosReparacion?.length)){
console.log('Sin fotos que guardar');
return;
}
try {
const db = await abrirIDB();
return new Promise((res, rej)=>{
const tx = db.transaction('fotos','readwrite');
tx.objectStore('fotos').put(JSON.stringify(fotos), parteId);
tx.oncomplete = ()=>{ console.log('Fotos guardadas en IDB para',parteId); res(); };
tx.onerror = (e)=>{ console.error('IDB error al guardar:',e); rej(e); };
});
} catch(e){ console.warn('guardarFotosIDB error:',e); }
}
async function cargarFotosIDB(parteId){
try {
const db = await abrirIDB();
return new Promise((res)=>{
const tx = db.transaction('fotos','readonly');
const req = tx.objectStore('fotos').get(parteId);
req.onsuccess = () => {
const fotos = req.result ? JSON.parse(req.result) : {};
console.log('cargarFotosIDB:', parteId, fotos);
res(fotos);
};
req.onerror = () => { console.warn('IDB error al cargar'); res({}); };
});
} catch(e){ console.warn('cargarFotosIDB error:',e); return {}; }
}
async function eliminarFotosIDB(parteId){
try {
const db = await abrirIDB();
return new Promise((res)=>{
const tx = db.transaction('fotos','readwrite');
tx.objectStore('fotos').delete(parteId);
tx.oncomplete = res;
tx.onerror = res;
});
} catch(e){}
}
let avisos=JSON.parse(localStorage.getItem('frimay_avisos')||'[]');
let avisoNum=avisos.length>0?Math.max(...avisos.map(a=>parseInt(a.numero)||0)):0;
// savePartes - ahora usamos Supabase (se llama tras operaciones)
const savePartes=()=>{}; // No-op: guardamos en Supabase directamente
const saveAvisos=()=>{}; // No-op: guardamos en Supabase directamente
const saveEquipos=()=>localStorage.setItem('frimay_equipos',JSON.stringify(equipos));
const ini=s=>(s||'??').split(' ').slice(0,2).map(w=>w[0]||'').join('').toUpperCase();
const fdate=d=>d?d.substring(0,10).split('-').reverse().join('/'):'—';
const calcTotal=p=>{
let t=0;
(p.repuestos||[]).forEach(r=>t+=r.precio*(r.cantidad||1));
(p.manoObra||[]).forEach(m=>t+=m.horas*m.precio);
return t+(parseFloat(p.desplazamiento)||0);
};
// ════════════════════════════════════════════════════════════════
// SUPABASE REALTIME — sincronización automática
// ════════════════════════════════════════════════════════════════
let _realtimeChannel = null;
// Recarga rápida desde Supabase sin tocar Factusol
let _recargarTimeout = null;
async function _recargarDesdeSupa(){
// Debounce: esperar 500ms por si llegan varios cambios seguidos
clearTimeout(_recargarTimeout);
_recargarTimeout = setTimeout(async ()=>{
try{
const [partesDB, avisosDB] = await Promise.all([
SupaPartes.getAll(),
SupaAvisos.getAll()
]);
const prevPartes = partes.length;
const prevAvisos = avisos.length;
partes = partesDB;
avisos = avisosDB;
// Notificar si hay cambios
if(partes.length > prevPartes){
const nuevo = partes[0];
_notificarCambio('Nuevo parte #'+nuevo.numero+' — '+(nuevo.clienteNombre||''));
} else if(avisos.length > prevAvisos){
const nuevo = avisos[0];
_notificarCambio('Nuevo aviso #'+nuevo.numero+' — '+(nuevo.clienteNombre||''));
}
// Actualizar cache offline
localStorage.setItem('frimay_partes_cache', JSON.stringify(partes));
localStorage.setItem('frimay_avisos_cache', JSON.stringify(avisos));
renderPage();
} catch(e){
console.warn('Error recargando desde Supabase:', e);
}
}, 500);
}
function iniciarRealtime(){
// Evitar canales duplicados
if(_realtimeChannel) return;
_realtimeChannel = db.channel('frimay-sync')
// Cambios en partes
.on('postgres_changes', {
event: '*', schema: 'public', table: 'partes'
}, (payload) => {
console.log('Realtime partes:', payload.eventType);
// Recargar todo desde Supabase
_recargarDesdeSupa();
})
// Cambios en avisos
.on('postgres_changes', {
event: '*', schema: 'public', table: 'avisos'
}, (payload) => {
console.log('Realtime avisos:', payload.eventType);
_recargarDesdeSupa();
})
.subscribe((status) => {
console.log('Realtime status:', status);
if(status === 'SUBSCRIBED'){
console.log('✓ Sincronización en tiempo real activa');
}
});
}
function pararRealtime(){
if(_realtimeChannel){
db.removeChannel(_realtimeChannel);
_realtimeChannel = null;
}
}
function _notificarCambio(mensaje){
// Toast de notificación
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);'
+'background:#1a1a18;color:#fff;padding:10px 18px;border-radius:99px;font-size:13px;'
+'font-weight:500;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.3);'
+'display:flex;align-items:center;gap:8px;white-space:nowrap;'
+'animation:fadeInUp .3s ease';
toast.innerHTML = '● ' + mensaje;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
}
// Normalizar artículos recibidos desde Factusol/API
// Acepta distintos nombres de campos para evitar que la app descarte artículos válidos.
function normalizarArticulosFactusol(lista){
if(!Array.isArray(lista)) return [];
return lista.map(a=>{
const codigo = a.codigo ?? a.CODART ?? a.codart ?? a.Codigo ?? a.codigoArticulo ?? a.CodigoArticulo ?? a.codArticulo ?? a.referencia ?? a.Referencia ?? a.ref ?? a.REF ?? ;
const descripcion = a.descripcion ?? a.DESART ?? a.desart ?? a.Descripcion ?? a.descripción ?? a.nombre ?? a.Nombre ?? a.descripcionArticulo ?? a.DescripcionArticulo ?? a.articulo ?? a.Articulo ?? ;
const pvp = a.pvp ?? a.PVP ?? a.precio ?? a.Precio ?? a.precioVenta ?? a.PrecioVenta ?? a.tarifa ?? a.Tarifa ?? 0;
const familia = a.familia ?? a.Familia ?? a.familia_nombre ?? a.FamiliaNombre ?? ;
const imagen = a.imagen ?? a.Imagen ?? a.foto ?? a.Foto ?? a.url_imagen ?? a.urlImagen ?? a.image ?? ;
return {
...a,
codigo: String(codigo || ).trim(),
descripcion: String(descripcion || ).trim(),
pvp: Number(String(pvp || 0).replace(,, .)) || 0,
familia,
imagen
};
}).filter(a=>a.codigo && a.descripcion);
}
// Forzar actualización de datos de Factusol
async function actualizarFactusol(){
const btn=document.getElementById('btn-refresh');
if(btn){ btn.style.animation='spin 1s linear infinite'; btn.disabled=true; }
setPill('loading','Actualizando...');
// Limpiar caché para forzar recarga completa
localStorage.removeItem('factusol_cache_ts');
try{
const [rc,ra]=await Promise.all([
fetch(API+'/api/clientes',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false})),
fetch(API+'/api/articulos',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false}))
]);
if(rc.ok&&rc.clientes&&rc.clientes.length){
clientes=rc.clientes;
localStorage.setItem('factusol_clientes',JSON.stringify(clientes));
}
if(ra.ok&&ra.articulos&&ra.articulos.length){
articulos=normalizarArticulosFactusol(ra.articulos);
localStorage.setItem('factusol_articulos',JSON.stringify(articulos));
localStorage.setItem('factusol_cache_ts',String(Date.now()));
}
setPill('ok',clientes.length+' clientes · '+articulos.length+' arts.');
renderPage();
}catch(e){
setPill('err','Error al actualizar');
console.warn('actualizarFactusol error:',e);
}finally{
if(btn){ btn.style.animation=''; btn.disabled=false; }
}
}
async function cargarDatos(){
const timer=setTimeout(()=>{renderPage();},10000);
// ── 1. Factusol: clientes y artículos (con caché 4h) ────────
try{
const CACHE_MS = 4 * 60 * 60 * 1000; // 4 horas
const cacheTS = parseInt(localStorage.getItem('factusol_cache_ts')||'0');
const cacheExpirada = (Date.now() - cacheTS) > CACHE_MS;
// Cargar caché inmediatamente (arranque instantáneo)
const cCli = localStorage.getItem('factusol_clientes');
const cArt = localStorage.getItem('factusol_articulos');
if(cCli) clientes = JSON.parse(cCli);
if(cArt) articulos = JSON.parse(cArt);
if(clientes.length > 0){
apiOk = true;
console.log('Factusol caché: '+clientes.length+' clientes, '+articulos.length+' arts.');
}
// Recargar si la caché expiró O si no hay artículos
if(cacheExpirada || clientes.length === 0 || articulos.length === 0){
console.log('Actualizando Factusol en segundo plano...');
Promise.all([
fetch(API+'/api/clientes',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false})),
fetch(API+'/api/articulos',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false}))
]).then(([rc,ra])=>{
let updated = false;
if(rc.ok&&rc.clientes&&rc.clientes.length){
clientes=rc.clientes;
localStorage.setItem('factusol_clientes', JSON.stringify(clientes));
updated = true;
}
if(ra.ok&&ra.articulos&&ra.articulos.length){
articulos=normalizarArticulosFactusol(ra.articulos);
localStorage.setItem('factusol_articulos', JSON.stringify(articulos));
localStorage.setItem('factusol_cache_ts', String(Date.now()));
updated = true;
}
if(updated){
apiOk = true;
setPill('ok', clientes.length+' clientes · '+articulos.length+' arts.');
console.log('Factusol actualizado: '+clientes.length+' clientes, '+articulos.length+' arts.');
if(currentPage==='articulos'||currentPage==='clientes') renderPage();
}
}).catch(e=>console.warn('Error Factusol:',e.message));
}
}catch(e){
console.warn('Error Factusol:',e.message);
apiOk=false;
}
// ── 2. Supabase: partes, avisos y equipos ────────────────────
try{
const [partesDB,avisosDB]=await Promise.all([
SupaPartes.getAll(),
SupaAvisos.getAll()
]);
partes=partesDB;
avisos=avisosDB;
console.log('Supabase: '+partes.length+' partes, '+avisos.length+' avisos');
// Cargar equipos de clientes relevantes
const cods=[...new Set([
...partes.map(p=>p.clienteCod),
...avisos.map(a=>a.clienteCod)
].filter(Boolean))];
await Promise.all(cods.map(async cod=>{
const eqs=await SupaEquipos.getPorCliente(cod);
equipos[cod]=eqs.map(e=>({
id:e.id,nombre:e.nombre,marca:e.marca,modelo:e.modelo,
serie:e.serie,año:e.anio,ubic:e.ubicacion,obs:e.obs,foto:e.foto_url
}));
}));
// Cache offline
localStorage.setItem('frimay_partes_cache',JSON.stringify(partes));
localStorage.setItem('frimay_avisos_cache',JSON.stringify(avisos));
}catch(e){
console.warn('Error Supabase:',e.message);
// Fallback a cache local
const pc=localStorage.getItem('frimay_partes_cache');
const ac=localStorage.getItem('frimay_avisos_cache');
if(pc) try{partes=JSON.parse(pc);}catch(_){}
if(ac) try{avisos=JSON.parse(ac);}catch(_){}
}
// ── 3. Actualizar UI ─────────────────────────────────────────
setPill(apiOk?'ok':'err',
apiOk ? clientes.length+' clientes · '+articulos.length+' arts.' : 'Sin conexión Factusol');
clearTimeout(timer);
renderPage();
}
function setPill(s,t){
const p=document.getElementById('api-pill');
p.className='api-chip '+s;
p.innerHTML=`${t}`;
}
function nav(page){
currentPage=page;
document.querySelectorAll('.sb-item').forEach(n=>n.classList.remove('active'));
document.getElementById('nav-'+page)?.classList.add('active');
mobNav(page);
renderPage();
}
function mobNav(page){
document.querySelectorAll('.mob-item').forEach(m=>m.classList.remove('active'));
const mob=document.getElementById('mob-'+page);
if(mob) mob.classList.add('active');
const pend=partes.filter(p=>p.estado==='pendiente').length;
const mb=document.getElementById('mob-badge-p');
if(mb){mb.textContent=pend;mb.style.display=pend?'':'none';}
}
function renderPage(){
const pend=partes.filter(p=>p.estado==='pendiente').length;
const bp=document.getElementById('badge-p');
if(bp){bp.textContent=pend;bp.style.display=pend?'':'none';}
const avisosNew=avisos.filter(a=>a.estado==='nuevo'||a.estado==='pendiente').length;
const bav=document.getElementById('badge-av');
if(bav){bav.textContent=avisosNew;bav.style.display=avisosNew?'':'none';}
const bavmob=document.getElementById('mob-badge-av');
if(bavmob){bavmob.textContent=avisosNew;bavmob.style.display=avisosNew?'':'none';}
const subs={dashboard:'Vista general del sistema',avisos:'Avisos de los clientes',partes:'Historial de partes de trabajo',clientes:'Base de datos de Factusol',articulos:'Catálogo sincronizado con Factusol',config:'Ajustes y tarifas',mapa:'Geolocalización de averías'};
const titles={dashboard:'Dashboard',avisos:'Avisos',partes:'Partes',clientes:'Clientes',articulos:'Artículos',config:'Configuración',mapa:'Mapa de averías'};
document.getElementById('page-title').textContent=titles[currentPage]||'';
document.getElementById('page-sub').textContent=subs[currentPage]||'';
document.getElementById('topbar-actions').innerHTML='';
const c=document.getElementById('content');
if(currentPage==='dashboard') pgDashboard(c);
else if(currentPage==='avisos') pgAvisos(c);
else if(currentPage==='partes') pgPartes(c);
else if(currentPage==='clientes') pgClientes(c);
else if(currentPage==='articulos') pgArticulos(c);
else if(currentPage==='config') pgConfig(c);
else if(currentPage==='mapa') pgMapa(c);
}
function addBtn(txt,cls,fn){
const b=document.createElement('button');
b.className='btn '+cls;b.innerHTML=txt;b.onclick=fn;
document.getElementById('topbar-actions').appendChild(b);
}
/* ── DASHBOARD ── */
function getEstados(){
const def=[
{nombre:'pendiente',color:'#c47d11',icono:'⏳'},
{nombre:'en progreso',color:'#2563a8',icono:'⚙'},
{nombre:'cerrado',color:'#2e7d32',icono:'✓'},
];
return JSON.parse(localStorage.getItem('cfg-estados')||JSON.stringify(def));
}
function badgeEstado(estado){
const estados=getEstados();
const e=estados.find(x=>x.nombre===estado)||{nombre:estado,color:'#888',icono:'●'};
return `
${e.nombre}
`;
}
function pgDashboard(c){
const estados=getEstados();
const tot=partes.reduce((s,p)=>s+calcTotal(p),0);
// Contar por cada estado
const conteos=estados.map(e=>({...e,count:partes.filter(p=>p.estado===e.nombre).length}));
// Estados desconocidos
const otros=partes.filter(p=>!estados.find(e=>e.nombre===p.estado));
c.innerHTML=`
${conteos.map((e,i)=>`
${e.icono}
${e.count}
${e.nombre}
`).join('')}
€
${tot.toFixed(0)}
Total facturado
Últimos partes
Los 10 más recientes
${partes.length===0?`
📋
Sin partes todavía
Crea tu primer parte de trabajo
`:
`
| Nº | Cliente | Equipo | Técnico | Fecha | Estado | Total |
${partes.slice(-10).reverse().map(p=>`
| #${p.numero} |
${p.clienteNombre||'—'} |
${p.equipoNombre||'—'} |
${p.tecnico||'—'} |
${fdate(p.fecha)} |
${badgeEstado(p.estado)} |
${calcTotal(p).toFixed(2)}€ |
`).join('')}
`}
`;
addBtn(' Nuevo parte','btn-primary',()=>abrirNuevoParte());
}
function pgPartes(c){
c.innerHTML=`
${partes.length} partes registrados
${partes.length===0?`
`:
`
| Nº | Cliente | Equipo | Fecha | Técnico | Tipo | Estado | Total |
${partes.slice().reverse().map(p=>`
| #${p.numero} |
${p.clienteNombre||'—'} |
${p.equipoNombre||'—'} |
${fdate(p.fecha)} |
${p.tecnico||'—'} |
${p.tipo||'—'} ${p.urgencia==='Urgente'?'!':''} |
${badgeEstado(p.estado)} |
${calcTotal(p).toFixed(2)}€ |
`).join('')}
`}
`;
addBtn('+ Nuevo parte','btn-primary',()=>abrirNuevoParte());
}
/* ── CLIENTES ── */
function pgClientes(c){
const lista=clientes.filter(cl=>{
if(!_cliBusq)return true;
const q=_cliBusq.toLowerCase();
return (cl.nombreFiscal||'').toLowerCase().includes(q)||(cl.nombreComercial||'').toLowerCase().includes(q)||String(cl.codigo).includes(q)||(cl.nif||'').toLowerCase().includes(q);
});
c.innerHTML=`
${lista.slice(0,200).map(cl=>`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal||'Sin nombre'}
${cl.nombreComercial?`${cl.nombreFiscal} · `:''}${cl.codigo}
`).join('')}
${lista.length>200?`
Mostrando 200 de ${lista.length}
`:''}
${_cliSel?'':emptyDetail()}
`;
if(_cliSel)selCli(_cliSel,true);
}
function emptyDetail(){
return`◉
Selecciona un cliente
Para ver su ficha completa
`;
}
function selCli(cod,keep){
_cliSel=cod;
document.querySelectorAll('.cli-row').forEach(r=>r.classList.remove('selected'));
const row=document.getElementById('cr-'+cod);
if(row){row.classList.add('selected');if(!keep)row.scrollIntoView({block:'nearest'});}
const cl=clientes.find(c=>c.codigo==cod)||{};
const eqs=equipos[cod]||[];
const det=document.getElementById('cli-detail');
if(!det)return;
const fr=(k,v,full)=>v?``:'';
// Función para generar URL Maps
function mapsUrl(cl){
const dir=[cl.domicilio,cl.cp,cl.poblacion,cl.provincia,cl.pais].filter(Boolean).join(', ');
return dir?encodeURIComponent(dir):'';
}
const mapsQ=mapsUrl(cl);
det.innerHTML=`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal||'—'}
${cl.nombreComercial?`
${cl.nombreFiscal}
`:''}
Cód. ${cl.codigo}
${cl.nif?`${cl.nif}`:''}
${cl.tarifa?`Tarifa ${cl.tarifa}`:''}
Dirección y contacto
${fr('Domicilio',cl.domicilio,true)}
${fr('Población / C.P.',cl.poblacion?(cl.cp?cl.cp+' — ':'')+cl.poblacion:cl.cp)}
${fr('Provincia',cl.provincia)}
${fr('País',cl.pais)}
${fr('Teléfono',cl.telefono?`
${cl.telefono}`:'')}
${fr('Email',cl.email?`
${cl.email}`:'')}
${fr('Web',cl.web?`
${cl.web}`:'')}
${fr('Fax',cl.fax)}
${[cl.contacto1,cl.contacto2,cl.contacto3].some(x=>x?.nombre)?`
Personas de contacto
${[cl.contacto1,cl.contacto2,cl.contacto3].filter(x=>x?.nombre).map((ct,i)=>`
`).join('')}`:''}
${mapsQ?`
Ubicación
Cómo llegar
${[cl.domicilio,cl.poblacion,cl.provincia].filter(Boolean).join(', ')}
`:''}
${cl.memo||cl.observaciones?`
Notas
${cl.memo?`
${cl.memo}
`:''}
${cl.observaciones?`
${cl.observaciones}
`:''}`:''}
Equipos instalados (${eqs.length})
${eqs.length===0?`
Sin equipos registrados
`:
`
${eqs.map((e,i)=>`
${e.foto?`

`:'⚙'}
${e.nombre}
${[e.marca,e.modelo].filter(Boolean).join(' ')}
S/N: ${e.serie||'—'}
`).join('')}
`}
`;
}
/* ── ARTÍCULOS ── */
function pgArticulos(c){
const lista=articulos.filter(a=>{
if(!_artBusq)return true;
const q=_artBusq.toLowerCase();
return (a.descripcion||'').toLowerCase().includes(q)||(a.codigo||'').toLowerCase().includes(q)||(a.codigoCorto||'').toLowerCase().includes(q);
});
const IMG='https://frimaypartesdetrabajo-production.up.railway.app/api/imagen/';
c.innerHTML=`
${articulos.length} artículos en catálogo
${lista.length} resultado${lista.length!==1?'s':''}
${_artVista==='lista'?`
${lista.slice(0,400).map(a=>`
${a.imagen?`

`:'📦'}
${a.descripcion}
${a.codigo}${a.codigoCorto?' · '+a.codigoCorto:''}
${(a.pvp||a.precio).toFixed(2)} €
`).join('')}
${lista.length>400?`
Mostrando 400 de ${lista.length} — usa el buscador
`:''}
`:`
${lista.slice(0,300).map(a=>`
${a.imagen?`

`:'📦'}
${a.descripcion}
${a.codigo}${a.codigoCorto?' · '+a.codigoCorto:''}
${(a.pvp||a.precio).toFixed(2)} €
`).join('')}
${lista.length>300?`
Mostrando 300 de ${lista.length} — usa el buscador
`:''}
`}
`;
}
let _artVista='lista';
function showPreview(e,img){
if(!img)return;
const el=document.getElementById('img-preview');
if(!el)return;
el.src='https://frimaypartesdetrabajo-production.up.railway.app/api/imagen/'+img;
el.style.display='block';
movePreview(e);
document.addEventListener('mousemove',movePreview);
}
function movePreview(e){
const el=document.getElementById('img-preview');
if(!el||el.style.display==='none')return;
const vw=window.innerWidth,vh=window.innerHeight;
const w=el.offsetWidth||200,h=el.offsetHeight||200;
el.style.left=(e.clientX+24+w>vw?e.clientX-w-12:e.clientX+16)+'px';
el.style.top=(e.clientY+h+10>vh?e.clientY-h-10:e.clientY+10)+'px';
}
function hidePreview(){
const el=document.getElementById('img-preview');
if(el){el.style.display='none';el.src='';}
document.removeEventListener('mousemove',movePreview);
}
function pgConfig(c){
const estados=getEstados();
c.innerHTML=`
Conexión Factusol
Servidor
https://frimaypartesdetrabajo-production.up.railway.app
Estado
${apiOk?'● Conectado':'● Sin conexión'}
Clientes
${clientes.length}
Artículos
${articulos.length}
Estados de los partes
${estados.map((e,i)=>`
`).join('')}
Arrastra el color para cambiar el tono. Los cambios se guardan automáticamente.
`;
}
function updateEstado(idx,campo,val){
const estados=getEstados();
estados[idx][campo]=val;
localStorage.setItem('cfg-estados',JSON.stringify(estados));
// Refrescar preview inline
const row=document.getElementById('erow-'+idx);
if(row){
const badge=row.querySelector('.badge-estado');
const color=estados[idx].color;
if(badge){
badge.style.background=color+'18';
badge.style.color=color;
badge.style.borderColor=color+'40';
badge.textContent=estados[idx].icono+' '+estados[idx].nombre;
}
}
}
function addEstado(){
const estados=getEstados();
const colores=['#8e24aa','#e53935','#039be5','#00897b','#f4511e','#3949ab'];
const col=colores[estados.length%colores.length];
estados.push({nombre:'Nuevo estado',color:col,icono:'📌'});
localStorage.setItem('cfg-estados',JSON.stringify(estados));
pgConfig(document.getElementById('content'));
}
function delEstado(idx){
if(!confirm('¿Eliminar este estado?'))return;
const estados=getEstados();
estados.splice(idx,1);
localStorage.setItem('cfg-estados',JSON.stringify(estados));
pgConfig(document.getElementById('content'));
}
function abrirNuevoParte(codCli,eqIdx){
window._np={cli:codCli||null,eqIdx:eqIdx!=null?eqIdx:null,data:{repuestos:[],manoObra:[],desplazamiento:parseFloat(localStorage.getItem('cfg-desp')||25),estado:'pendiente'}};
const mo=mkOverlay('modal-parte');
mo.innerHTML=`
${['1. Cliente','2. Diagnóstico','3. Repuestos','4. M. Obra','5. Cierre'].map((t,i)=>`
${t}
`).join('')}
`;
document.body.appendChild(mo);pTab(1);
}
function pTab(n){
// Guardar datos del tab actual antes de cambiar
const _cur=window._pTabActual||0;
if(_cur===1) sP1();
else if(_cur===2) sP2();
else if(_cur===4) sP4();
else if(_cur===5) sP5();
window._pTabActual=n;
document.querySelectorAll('.tab[id^="ptab"]').forEach((t,i)=>t.classList.toggle('active',i+1===n));
const b=document.getElementById('pb'),d=window._np.data;
const tecs=(localStorage.getItem('cfg-tecnicos')||'Técnico 1').split(',').map(t=>t.trim()).filter(Boolean);
if(n===1){
const codCli=window._np.cli;
const eqs=codCli?(equipos[codCli]||[]):[];
b.innerHTML=`
`;
setTimeout(()=>{
// Marcar cliente activo si existe
if(codCli){
const el=document.getElementById('npc-'+codCli);
if(el)el.classList.add('selected');
}
const lista=document.getElementById('np-cl');
const inp=document.getElementById('np-cs');
if(window._np&&window._np.editId&&codCli){
// Edición con cliente: input readonly + clic para cambiar
if(lista)lista.style.display='none';
if(inp){
inp.setAttribute('readonly','readonly');
inp.style.cursor='pointer';
inp.style.background='var(--red-soft)';
inp.title='Haz clic para buscar otro cliente';
inp.addEventListener('click',function(){
this.removeAttribute('readonly');
this.style.cursor='text';
this.style.background='';
this.value='';
if(lista){lista.style.display='';filtNpCli('');}
this.focus();
},{once:true});
}
} else {
// Nuevo parte o edición sin cliente: ocultar lista hasta que escriban
if(lista)lista.style.display='none';
if(inp){
inp.addEventListener('input',function(){
if(lista)lista.style.display=this.value.trim()?'':'none';
});
// Si ya hay texto (raro), mostrar lista
if(inp.value.trim()&&lista)lista.style.display='';
}
}
},50);
}else if(n===2){
b.innerHTML=`
Diagnóstico
`;
_renderFotosGrid();
}else if(n===3){
const rep=d.repuestos||[];
b.innerHTML=`
Repuestos imputados
${rep.map((r,i)=>rRow(r,i)).join('')}
Total materiales${rep.reduce((s,r)=>s+r.precio*(r.cantidad||1),0).toFixed(2)} €
`;
}else if(n===4){
const mo2=d.manoObra||[];
const th=parseFloat(localStorage.getItem('cfg-hora')||45);
const thu=parseFloat(localStorage.getItem('cfg-hora-urg')||65);
b.innerHTML=`
Mano de obra
${mo2.map((m,i)=>mRow(m,i)).join('')}
Desplazamiento
Mano de obra0.00 €
Desplazamiento0.00 €
Materiales${(d.repuestos||[]).reduce((s,r)=>s+r.precio*(r.cantidad||1),0).toFixed(2)} €
TOTAL PARTE0.00 €
`;
uT();
}else if(n===5){
b.innerHTML=`
TOTAL PARTE${calcTotal(d).toFixed(2)} €
`;
iF();
}
}
function filtNpCli(q){
const l=clientes.filter(c=>(c.nombreFiscal||'').toLowerCase().includes(q.toLowerCase())||(c.nombreComercial||'').toLowerCase().includes(q.toLowerCase())||String(c.codigo).includes(q)).slice(0,50);
document.getElementById('np-cl').innerHTML=l.map(cl=>`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal}
${cl.codigo}
`).join('');
}
function selNpCli(cod){
window._np.cli=cod;
const cl=clientes.find(c=>c.codigo==cod)||{};
document.getElementById('np-cs').value=cl.nombreComercial||cl.nombreFiscal||'';
document.querySelectorAll('[id^="npc-"]').forEach(r=>r.classList.remove('selected'));
const el=document.getElementById('npc-'+cod);if(el)el.classList.add('selected');
const eqs=equipos[cod]||[];
const sel=document.getElementById('np-eq');
if(sel)sel.innerHTML=`${eqs.map((e,i)=>``).join('')}`;
}
const rRow=(r,i)=>`
${r.imagen?`

`:'◈'}
${r.nombre}
`
const mRow=(m,i)=>`${m.tipo} — ${m.horas}h × ${m.precio}€/h
${(m.horas*m.precio).toFixed(2)}€
`;
// Art seleccionado actualmente
window._artSel=null;
function filtArt(q){
const res=document.getElementById('art-drop');
const info=document.getElementById('art-sel-info');
if(!q||q.length<2){res.style.display='none';return;}
const ql=q.toLowerCase();
const lista=articulos.filter(a=>
(a.codigo||'').toLowerCase().includes(ql)||
(a.codigoCorto||'').toLowerCase().includes(ql)||
(a.descripcion||'').toLowerCase().includes(ql)
).slice(0,40);
if(!lista.length){res.style.display='none';return;}
res.style.display='block';
res.innerHTML=lista.map(a=>`
${a.imagen ? '

' : '◈'}
${a.descripcion}
${a.codigo}${a.codigoCorto?' · '+a.codigoCorto:''}
${(a.pvp||a.precio).toFixed(2)}€
`).join('');
}
function selArt(el){
const cod = el.dataset.cod;
const nom = el.dataset.nom;
const precio= parseFloat(el.dataset.pvp)||0;
const imagen= el.dataset.img||'';
window._np.data.repuestos.push({codigo:cod,nombre:nom,precio:precio,cantidad:1,imagen:imagen});
document.getElementById('rl').innerHTML=window._np.data.repuestos.map((r,i)=>rRow(r,i)).join('');
uRt();
document.getElementById('art-busq').value='';
document.getElementById('art-drop').style.display='none';
}
function addR(){
if(!window._artSel){alert('Selecciona un artículo del buscador');return;}
const a=window._artSel;
const precioInp=document.getElementById('art-sel-precio');
const precioFinal=precioInp?parseFloat(precioInp.value)||a.precio:a.precio;
window._np.data.repuestos.push({codigo:a.codigo,nombre:a.nombre,precio:precioFinal,cantidad:1});
document.getElementById('rl').innerHTML=window._np.data.repuestos.map((r,i)=>rRow(r,i)).join('');
uRt();
// Limpiar selección
window._artSel=null;
document.getElementById('art-busq').value='';
document.getElementById('art-sel-info').style.display='none';
document.getElementById('art-drop').style.display='none';
}
function dR(i){window._np.data.repuestos.splice(i,1);document.getElementById('rl').innerHTML=window._np.data.repuestos.map((r,i)=>rRow(r,i)).join('');uRt();}
function uRt(){const t=window._np.data.repuestos.reduce((s,r)=>s+r.precio*(r.cantidad||1),0);const e=document.getElementById('rt');if(e)e.textContent=t.toFixed(2)+' €';}
function addM(){const t=document.getElementById('mt');const h=parseFloat(document.getElementById('mh').value)||1;const p=parseFloat(t.value);const l=t.options[t.selectedIndex].text.split(' ')[0];window._np.data.manoObra.push({tipo:l,horas:h,precio:p});document.getElementById('ml').innerHTML=window._np.data.manoObra.map((m,i)=>mRow(m,i)).join('');uT();}
function dM(i){window._np.data.manoObra.splice(i,1);document.getElementById('ml').innerHTML=window._np.data.manoObra.map((m,i)=>mRow(m,i)).join('');uT();}
function cD(){
const nk=document.getElementById('nk');
const km=parseFloat(nk?.value)||0;
const b=parseFloat(localStorage.getItem('cfg-desp')||25);
const pk=parseFloat(localStorage.getItem('cfg-km')||0.35);
const desp=b+km*pk;
const nd=document.getElementById('nd');
if(nd) nd.value=desp.toFixed(2);
if(window._np?.data){ window._np.data.km=km; window._np.data.desplazamiento=desp; }
uT();
}
function uT(){
const mo=(window._np.data.manoObra||[]).reduce((s,m)=>s+m.horas*m.precio,0);
const d=parseFloat(document.getElementById('nd')?.value||0);
const r=(window._np.data.repuestos||[]).reduce((s,r)=>s+r.precio*(r.cantidad||1),0);
window._np.data.desplazamiento=d;
const e1=document.getElementById('mot');if(e1)e1.textContent=mo.toFixed(2)+' €';
const e2=document.getElementById('det');if(e2)e2.textContent=d.toFixed(2)+' €';
const e3=document.getElementById('tt');if(e3)e3.textContent=(mo+d+r).toFixed(2)+' €';
}
function sP1(){
const d=window._np.data;
d.clienteCod=window._np.cli;
const tec=document.getElementById('np-tec');
const fecha=document.getElementById('np-fecha');
const tipo=document.getElementById('np-tipo');
const urg=document.getElementById('np-urg');
const eq=document.getElementById('np-eq');
// Solo guardar si el campo existe en el DOM
if(tec!==null) d.tecnico=tec.value;
if(fecha!==null) d.fecha=fecha.value;
if(tipo!==null) d.tipo=tipo.value;
if(urg!==null) d.urgencia=urg.value;
if(eq!==null&&eq.value&&eq.value!=='nuevo') window._np.eqIdx=parseInt(eq.value);
}
function sP4(){
const nk=document.getElementById('nk');
const nd=document.getElementById('nd');
if(nk!==null&&window._np?.data){ window._np.data.km=parseFloat(nk.value)||0; }
if(nd!==null&&window._np?.data){ window._np.data.desplazamiento=parseFloat(nd.value)||0; }
}
function sP5(){
const no=document.getElementById('no');
const ne=document.getElementById('ne');
if(no!==null&&window._np?.data) window._np.data.obs=no.value;
if(ne!==null&&window._np?.data) window._np.data.estado=ne.value;
}
function sP2(){
const d=window._np.data;
const prob=document.getElementById('np-prob');
const diag=document.getElementById('np-diag');
const trab=document.getElementById('np-trab');
// Solo guardar si el campo existe en el DOM (estamos en tab2)
if(prob!==null) d.problema=prob.value;
if(diag!==null) d.diagnostico=diag.value;
if(trab!==null) d.trabajos=trab.value;
}
let fd=false,fc2=null,fce=null;
function iF(){
const cv=document.getElementById('fc');if(!cv)return;
fce=cv;fc2=cv.getContext('2d');fc2.strokeStyle='#4d8dff';fc2.lineWidth=2;fc2.lineCap='round';
const pos=(e,r)=>{const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(cv.width/r.width),y:(t.clientY-r.top)*(cv.height/r.height)};};
cv.addEventListener('mousedown',e=>{fd=true;const p=pos(e,cv.getBoundingClientRect());fc2.beginPath();fc2.moveTo(p.x,p.y);document.getElementById('fph').style.display='none';});
cv.addEventListener('mousemove',e=>{if(!fd)return;const p=pos(e,cv.getBoundingClientRect());fc2.lineTo(p.x,p.y);fc2.stroke();});
cv.addEventListener('mouseup',()=>{
fd=false;
if(fce){
window._np.data.firma=fce.toDataURL();
_marcarFirmado();
}
});
cv.addEventListener('touchstart',e=>{e.preventDefault();fd=true;const p=pos(e,cv.getBoundingClientRect());fc2.beginPath();fc2.moveTo(p.x,p.y);document.getElementById('fph').style.display='none';},{passive:false});
cv.addEventListener('touchmove',e=>{e.preventDefault();if(!fd)return;const p=pos(e,cv.getBoundingClientRect());fc2.lineTo(p.x,p.y);fc2.stroke();},{passive:false});
cv.addEventListener('touchend',()=>{
fd=false;
if(fce){
window._np.data.firma=fce.toDataURL();
_marcarFirmado();
}
});
}
function bF(){if(fc2&&fce){fc2.clearRect(0,0,fce.width,fce.height);window._np.data.firma=null;const p=document.getElementById('fph');if(p)p.style.display='';}}
function gP(){
sP1();sP2();sP4();sP5();
const d=window._np.data;
const cl=clientes.find(c=>c.codigo==d.clienteCod)||{};
const eqs=equipos[d.clienteCod]||[];
const eq=window._np.eqIdx!=null?eqs[window._np.eqIdx]:null;
const nkEl=document.getElementById('nk');
const ndEl=document.getElementById('nd');
if(nkEl) d.km=parseFloat(nkEl.value)||0;
if(ndEl) d.desplazamiento=parseFloat(ndEl.value)||0;
const noEl=document.getElementById('no');
const neEl=document.getElementById('ne');
if(noEl) d.obs=noEl.value;
if(neEl) d.estado=neEl.value;
const parteData={
avisoId:window._np.avisoId||null,
clienteCod:d.clienteCod, clienteNombre:cl.nombreComercial||cl.nombreFiscal||'—',
equipoNombre:eq?.nombre||'—', equipoId:eq?.id||null,
tecnico:d.tecnico, fecha:d.fecha, tipo:d.tipo,
problema:d.problema, trabajos:d.trabajos,
repuestos:d.repuestos||[], manoObra:d.manoObra||[],
desplazamiento:d.desplazamiento||0, km:d.km||0,
obs:d.obs||'', estado:d.estado||'pendiente',
firma:d.firma||null, firmaGeo:d.firmaGeo||null,
fotosCliente:d.fotosCliente||[]
};
// Mostrar spinner
const saveBtn=document.querySelector('[onclick="gP()"]');
if(saveBtn){saveBtn.disabled=true;saveBtn.textContent='Guardando...';}
if(window._np.editId){
// EDITAR en Supabase
SupaPartes.actualizar(window._np.editId, parteData).then(async ()=>{
// Actualizar en memoria
const idx=partes.findIndex(x=>x.id===window._np.editId);
if(idx>=0){
const updated=await SupaPartes.getById(window._np.editId);
partes[idx]=updated;
}
// Vincular aviso si existe
if(window._np.avisoId){
const estados=getEstados();
const estCurso=estados.find(e=>e.nombre.toLowerCase().includes('curso'))||estados[1]||estados[0];
const avIdx=avisos.findIndex(x=>x.id===window._np.avisoId);
if(avIdx>=0){
avisos[avIdx].estado=estCurso.nombre;
await SupaAvisos.actualizar(window._np.avisoId,{...avisos[avIdx]});
}
}
cerrar('modal-parte');
renderPage();
if(currentPage==='partes'||currentPage==='dashboard'){
const cont=document.getElementById('content');
if(cont){if(currentPage==='partes')pgPartes(cont);else pgDashboard(cont);}
}
verParte(window._np.editId);
}).catch(e=>{
alert('Error guardando: '+e.message);
if(saveBtn){saveBtn.disabled=false;saveBtn.textContent='Guardar';}
});
} else {
// CREAR en Supabase
SupaPartes.crear(parteData).then(async (newId)=>{
// Actualizar aviso si existe
if(window._np.avisoId){
const estados=getEstados();
const estCurso=estados.find(e=>e.nombre.toLowerCase().includes('curso'))||estados[1]||estados[0];
await SupaAvisos.vincularParte(window._np.avisoId, newId, estCurso.nombre);
const avIdx=avisos.findIndex(x=>x.id===window._np.avisoId);
if(avIdx>=0){avisos[avIdx].estado=estCurso.nombre;avisos[avIdx].parteId=newId;}
}
// Recargar partes
const nuevoParte=await SupaPartes.getById(newId);
partes.unshift(nuevoParte);
cerrar('modal-parte');
nav('partes');
}).catch(e=>{
alert('Error guardando: '+e.message);
if(saveBtn){saveBtn.disabled=false;saveBtn.textContent='Guardar';}
});
}
}
function verParte(id){
const p=partes.find(x=>x.id===id);if(!p)return;
// Las fotos ya vienen de Supabase en el objeto p
// Solo combinar con IDB si hay fotos locales adicionales
cargarFotosIDB(id).then(fotos=>{
const pConFotos={...p};
// Solo usar IDB si tiene fotos (no sobreescribir las de Supabase)
if(fotos&&fotos.fotosCliente&&fotos.fotosCliente.length)
pConFotos.fotosCliente=fotos.fotosCliente;
if(fotos&&fotos.fotosReparacion&&fotos.fotosReparacion.length)
pConFotos.fotosReparacion=fotos.fotosReparacion;
_verParteConFotos(pConFotos);
}).catch(()=>_verParteConFotos(p));
}
function _verParteConFotos(p){
const tot=calcTotal(p);
const mo=mkOverlay('modal-ver');
mo.innerHTML=`
${badgeEstado(p.estado)}
Cliente
${p.clienteNombre}
Fecha
${fdate(p.fecha)} ${p.urgencia==='Urgente'?'URGENTE':''}
${p.problema?`
`:''}
${p.diagnostico?`
Diagnóstico
${p.diagnostico}
`:''}
${p.trabajos?`
`:''}
${p.repuestos?.length?`
Repuestos
${p.repuestos.map(r=>`
${r.nombre}${r.cantidad}×${r.precio.toFixed(2)}€
`).join('')}
`:''}
${p.manoObra?.length?`
Mano de obra
${p.manoObra.map(m=>`
${m.tipo} — ${m.horas}h${(m.horas*m.precio).toFixed(2)}€
`).join('')}
`:''}
Materiales${(p.repuestos||[]).reduce((s,r)=>s+r.precio*(r.cantidad||1),0).toFixed(2)} €
Mano de obra${(p.manoObra||[]).reduce((s,m)=>s+m.horas*m.precio,0).toFixed(2)} €
Desplazamiento${(p.desplazamiento||0).toFixed(2)} €
TOTAL${tot.toFixed(2)} €
${p.firma?`
Firma del cliente

${p.firmaGeo?`
📍
Ubicación registrada
· ±${p.firmaGeo.acc||'?'}m de precisión
🗺 Google Maps