🧾 COTIZACIONES
FolioClienteVálida hastaTotalAcciones
💰 UTILIDAD NETA
ANÁLISIS DE COTIZACIONES APROBADAS Utilidad neta del periodo: $0.00 (Margen promedio: 0.0%)
🎯 SISTEMA DE SEGUIMIENTO DE CLIENTES
BALEROS Y COMPLEMENTOS DE MÉXICO - BACMEX
ID ↕ NOMBRE ↕ TELÉFONO ↕ CORREO ↕ ORIGEN ↕ FECHA COTIZACIÓN ↕ SEG. 1 FECHA SEG. 1 SEG. 2 FECHA SEG. 2 SEG. 3 FECHA SEG. 3 ESTADO ↕ PROB. ↕ NOTAS DÍAS SIN CONTACTO ↕ ACCIONES
📊 DASHBOARD DE MÉTRICAS Y RENDIMIENTO
ANÁLISIS EN TIEMPO REAL - BACMEX

🎯 Progreso Meta Mensual

Meta del Mes:20 ventas
0%

📈 Cotizado vs Utilidad (mensual)

📇 CARTERA COMPLETA DE CLIENTES
DIRECTORIO Y CLASIFICACIÓN - BACMEX
IDRAZÓN SOCIALCONTACTOTELÉFONOCORREODIRECCIÓNCIUDADTIPOPRODUCTOSVOLUMENNOTASACCIONES
📖 GUÍA DE USO DEL SISTEMA
MANUAL INTERACTIVO - BACMEX

🚀 Cómo Usar el Sistema

✨ Características

  • Búsqueda Instantánea: Encuentra clientes en tiempo real
  • Filtros Inteligentes: Por estado y nivel de alerta
  • Ordenamiento: Click en cualquier columna
  • Edición Rápida: Botones para editar/eliminar
  • Dashboard en Vivo: Métricas actualizadas

⚡ REGLAS DE ORO

  1. Nunca dejes pasar más de 2 días sin contactar
  2. Clientes con alerta roja (6+ días) son PRIORIDAD #1
  3. 80% de las ventas se cierran después del 2do seguimiento
  4. Actualiza el sistema TODOS LOS DÍAS
  5. Meta: 20-30% tasa de conversión
`); w.document.close(); }catch{}} function calcularDias(fechaUltimo){ if (!fechaUltimo) return 999; const hoy = new Date(); const fecha = new Date(fechaUltimo); return Math.floor((hoy - fecha)/(1000*60*60*24)); } function getUltimaFecha(client){ const fechas = [client.fechaSeg3, client.fechaSeg2, client.fechaSeg1].filter(Boolean); return fechas[0] || client.fecha; } function renderClients(){ const tbody = document.getElementById('tableBody'); const rows = clients.map((client, index) => { const ultimaFecha = getUltimaFecha(client); const dias = calcularDias(ultimaFecha); const diasClass = dias >= 6 ? 'dias-rojo' : (dias >= 3 ? 'dias-amarillo' : 'dias-verde'); const estadoClass = client.estado === 'En Proceso' ? 'estado-proceso' : (client.estado === 'Cerrado' ? 'estado-cerrado' : (client.estado === 'Perdido' ? 'estado-perdido' : 'estado-nuevo')); return ` ${client.id} ${client.name} ${client.phone} ${client.email} ${client.origen} ${formatDate(client.fecha)} ${client.seg1||''} ${client.fechaSeg1 ? formatDate(client.fechaSeg1) : '-'} ${client.seg2||''} ${client.fechaSeg2 ? formatDate(client.fechaSeg2) : '-'} ${client.seg3||''} ${client.fechaSeg3 ? formatDate(client.fechaSeg3) : '-'} ${client.estado} ${client.prob}% ${client.notas||''} ${dias}
`; }).join(''); tbody.innerHTML = rows; } function formatDate(dateStr){ if (!dateStr) return '-'; const d = new Date(dateStr); return isNaN(d)? '-': d.toLocaleDateString('es-MX'); } function filterTable(){ const searchTerm = document.getElementById('searchInput').value.toLowerCase(); const estadoFilter = document.getElementById('estadoFilter').value; const alertaFilter = document.getElementById('alertaFilter').value; // 'verde' | 'amarillo' | 'rojo' const rows = document.querySelectorAll('#tableBody tr'); rows.forEach(row => { const text = row.textContent.toLowerCase(); const estado = row.querySelector('.estado')?.textContent || ''; const diasElement = row.querySelector('.dias'); let showRow = true; if (searchTerm && !text.includes(searchTerm)) showRow = false; if (estadoFilter && estado !== estadoFilter) showRow = false; if (alertaFilter && !diasElement?.classList.contains('dias-' + alertaFilter)) showRow = false; row.style.display = showRow ? '' : 'none'; }); } function sortTable(columnIndex){ const table = document.getElementById('clientsTable'); const rows = Array.from(table.querySelectorAll('tbody tr')); rows.sort((a,b)=>{ const av = a.cells[columnIndex]?.textContent.trim() || ''; const bv = b.cells[columnIndex]?.textContent.trim() || ''; if (!isNaN(av) && !isNaN(bv)) return parseFloat(av) - parseFloat(bv); return av.localeCompare(bv); }); const tbody = table.querySelector('tbody'); rows.forEach(r=>tbody.appendChild(r)); } // Generador de ID para clientes: CLI-### function getNextClientId(){ try{ const nums = (clients||[]) .map(c=> (c.id||'').match(/^CLI-(\d{1,})$/)) .filter(Boolean) .map(m=> parseInt(m[1],10)) .filter(n=> !isNaN(n)); const next = (nums.length? Math.max(...nums): 0) + 1; return `CLI-${String(next).padStart(3,'0')}`; }catch{ return `CLI-${String(Date.now()%1000).padStart(3,'0')}`; } } function openAddModal(){ document.getElementById('modalTitle').textContent = 'Nuevo Cliente'; document.getElementById('clientForm').reset(); document.getElementById('editIndex').value = ''; document.getElementById('clientFecha').valueAsDate = new Date(); // Asignar ID automático por defecto const idEl = document.getElementById('clientId'); if (idEl) idEl.value = getNextClientId(); document.getElementById('clientModal').classList.add('active'); // Restaurar borrador si existe const d = getDraft(DRAFT_CLIENT_KEY); if (d){ ['clientId','clientName','clientPhone','clientEmail','clientOrigen','clientFecha','clientEstado','clientProb','clientSeg1','clientFechaSeg1','clientSeg2','clientFechaSeg2','clientSeg3','clientFechaSeg3','clientNotas'].forEach(id=>{ if (d[id]!==undefined) document.getElementById(id).value = d[id]; }); } // Autosave en vivo const saveDraftClient = debounce(function(){ const f={}; ['clientId','clientName','clientPhone','clientEmail','clientOrigen','clientFecha','clientEstado','clientProb','clientSeg1','clientFechaSeg1','clientSeg2','clientFechaSeg2','clientSeg3','clientFechaSeg3','clientNotas'].forEach(id=> f[id]=document.getElementById(id).value ); setDraft(DRAFT_CLIENT_KEY,f); }, 800); document.getElementById('clientForm').oninput = saveDraftClient; } function closeModal(){ document.getElementById('clientModal').classList.remove('active'); clearDraft(DRAFT_CLIENT_KEY); } function editClient(index){ const c = clients[index]; document.getElementById('modalTitle').textContent = 'Editar Cliente'; document.getElementById('editIndex').value = index; document.getElementById('clientId').value = c.id; document.getElementById('clientName').value = c.name; document.getElementById('clientPhone').value = c.phone; document.getElementById('clientEmail').value = c.email; document.getElementById('clientOrigen').value = c.origen; document.getElementById('clientFecha').value = c.fecha; document.getElementById('clientEstado').value = c.estado; document.getElementById('clientProb').value = c.prob; document.getElementById('clientSeg1').value = c.seg1 || ''; document.getElementById('clientFechaSeg1').value = c.fechaSeg1 || ''; document.getElementById('clientSeg2').value = c.seg2 || ''; document.getElementById('clientFechaSeg2').value = c.fechaSeg2 || ''; document.getElementById('clientSeg3').value = c.seg3 || ''; document.getElementById('clientFechaSeg3').value = c.fechaSeg3 || ''; document.getElementById('clientNotas').value = c.notas || ''; document.getElementById('clientModal').classList.add('active'); } async function deleteClient(index){ const ok = await confirmModal('¿Estás seguro de eliminar este cliente?'); if (ok){ const delId = clients[index]?.id; clients.splice(index,1); saveArray(LS_KEY, clients); try{ const c=getSupa(); if (c && delId){ await c.from('clients').delete().eq('id', delId); } }catch{} renderClients(); updateDashboard(); } } function saveClient(e){ e.preventDefault(); // Asegurar ID si viene vacío const providedId = (document.getElementById('clientId').value || '').trim(); const client = { id: providedId || getNextClientId(), name: document.getElementById('clientName').value.trim(), phone: document.getElementById('clientPhone').value.trim(), email: document.getElementById('clientEmail').value.trim(), origen: document.getElementById('clientOrigen').value, fecha: document.getElementById('clientFecha').value, estado: document.getElementById('clientEstado').value, prob: parseInt(document.getElementById('clientProb').value || '0', 10), seg1: document.getElementById('clientSeg1').value, fechaSeg1: document.getElementById('clientFechaSeg1').value, seg2: document.getElementById('clientSeg2').value, fechaSeg2: document.getElementById('clientFechaSeg2').value, seg3: document.getElementById('clientSeg3').value, fechaSeg3: document.getElementById('clientFechaSeg3').value, notas: document.getElementById('clientNotas').value }; const idx = document.getElementById('editIndex').value; if (idx !== '') clients[idx] = client; else clients.push(client); saveArray(LS_KEY, clients); (async()=>{ try{ const c=getSupa(); if (c){ await c.from('clients').upsert([{...client, updated_at:new Date().toISOString()}], { onConflict:'id' }); } }catch{} })(); renderClients(); updateDashboard(); clearDraft(DRAFT_CLIENT_KEY); closeModal(); } function updateDashboard(){ const total = clients.length; const nuevos = clients.filter(c=>c.estado==='Nuevo').length; const proceso = clients.filter(c=>c.estado==='En Proceso').length; const cerrados = clients.filter(c=>c.estado==='Cerrado').length; const perdidos = clients.filter(c=>c.estado==='Perdido').length; const urgentes = clients.filter(c=>{ const d=calcularDias(getUltimaFecha(c)); return d>=6 && c.estado!=='Cerrado' && c.estado!=='Perdido'; }).length; const atencion = clients.filter(c=>{ const d=calcularDias(getUltimaFecha(c)); return d>=3 && d<6 && c.estado!=='Cerrado' && c.estado!=='Perdido'; }).length; const alDia = clients.filter(c=>{ const d=calcularDias(getUltimaFecha(c)); return d<3 && c.estado!=='Cerrado' && c.estado!=='Perdido'; }).length; const tasaConversion = total? ((cerrados/total)*100).toFixed(1): 0; const tasaAbandono = total? ((perdidos/total)*100).toFixed(1): 0; const probPromedio = total? Math.round(clients.reduce((s,c)=>s+c.prob,0)/total): 0; const tiempoPromResp = total? (clients.reduce((s,c)=>s+calcularDias(getUltimaFecha(c)),0)/total).toFixed(1): 0; const activos = nuevos + proceso; const metaMensual = 20; const progreso = ((cerrados/metaMensual)*100).toFixed(1); document.getElementById('metaProgress').style.width = progreso + '%'; document.getElementById('metaProgress').textContent = progreso + '%'; // Utilidad: card usando config const ucfg = getUtilCfg(); const fmt = v=> new Intl.NumberFormat('es-MX',{ style:'currency', currency:'MXN' }).format(v||0); const monthly = new Map(); // key YYYY-MM -> { total, net } (window.quotes||[]).forEach(q=>{ const t = calcQuoteTotals(q); const key = (q.date? q.date.slice(0,7): new Date().toISOString().slice(0,7)); const cost = t.total * (ucfg.costPct/100); const opex = t.total * (ucfg.opexPct/100) + (ucfg.opexFixed||0); const net = t.total - cost - opex; const isApproved = (q.status||'').toLowerCase() === (ucfg.status||'Aprobada').toLowerCase(); if (!monthly.has(key)) monthly.set(key, { total:0, net:0 }); const acc = monthly.get(key); acc.total += t.total; if (isApproved) acc.net += net; monthly.set(key, acc); }); const approvedQuotes = (window.quotes||[]).filter(q=> (q.status||'').toLowerCase() === (ucfg.status||'Aprobada').toLowerCase()); const aggApproved = approvedQuotes.reduce((a,q)=>{ const t=calcQuoteTotals(q); const net = t.total - (t.total*ucfg.costPct/100) - (t.total*ucfg.opexPct/100) - (ucfg.opexFixed||0); a.total+=t.total; a.net+=net; return a; }, { total:0, net:0 }); document.getElementById('dashboardContent').innerHTML = `
📊 ESTADÍSTICAS GENERALES
Total Clientes:${total}
Clientes Nuevos:${nuevos}
En Proceso:${proceso}
Ventas Cerradas:${cerrados}
Clientes Perdidos:${perdidos}
⚠ ALERTAS DE SEGUIMIENTO
🔴 Urgente (6+ días):${urgentes}
🟡 Atención (3-5 días):${atencion}
🟢 Al día (0-2 días):${alDia}
📈 MÉTRICAS CLAVE
Tasa Conversión:${tasaConversion}%
Tasa Abandono:${tasaAbandono}%
Prob. Promedio:${probPromedio}%
Tiempo Prom. Resp:${tiempoPromResp} días
Clientes Activos:${activos}
`; } function renderCartera(){ const tbody = document.getElementById('carteraBody'); const tipoColors = { 'Corporativo':'#90EE90', 'Distribuidor':'#ADD8E6', 'Industrial':'#FFFFC8', 'PyME':'#FFE4C4', 'Detallista':'#E6E6FA' }; const volColors = { 'Alto':'#90EE90', 'Medio':'#FFFF00', 'Bajo':'#FFC8C8' }; const rows = cartera.map((item, index) => ` ${item.id} ${item.razon} ${item.contacto} ${item.tel} ${item.email} ${item.direccion||''} ${item.ciudad||''} ${item.tipo} ${item.productos||''} ${item.volumen} ${item.notas||''}
`).join(''); tbody.innerHTML = rows; } function filterCartera(){ const searchTerm = document.getElementById('searchCartera').value.toLowerCase(); const rows = document.querySelectorAll('#carteraBody tr'); rows.forEach(row => { row.style.display = row.textContent.toLowerCase().includes(searchTerm) ? '' : 'none'; }); } // Generador de ID para cartera basado en existentes (CAR-###) function getNextCarteraId(){ try{ const nums = (cartera||[]) .map(c=> (c.id||'').match(/^CAR-(\d{1,})$/)) .filter(Boolean) .map(m=> parseInt(m[1],10)) .filter(n=> !isNaN(n)); const next = (nums.length? Math.max(...nums): 0) + 1; return `CAR-${String(next).padStart(3,'0')}`; }catch{ return `CAR-${String(Date.now()%1000).padStart(3,'0')}`; } } function openAddCarteraModal(){ document.getElementById('carteraForm').reset(); document.getElementById('carteraModal').classList.add('active'); // Prellenar ID automático const idEl = document.getElementById('carteraId'); if (idEl) idEl.value = getNextCarteraId(); const d=getDraft(DRAFT_CARTERA_KEY); if (d){ ['carteraId','carteraRazon','carteraContacto','carteraTel','carteraEmail','carteraDireccion','carteraCiudad','carteraTipo','carteraProductos','carteraVolumen','carteraNotas'].forEach(id=>{ if (d[id]!==undefined) document.getElementById(id).value=d[id]; }); } const saveDraftCar = debounce(function(){ const f={}; ['carteraId','carteraRazon','carteraContacto','carteraTel','carteraEmail','carteraDireccion','carteraCiudad','carteraTipo','carteraProductos','carteraVolumen','carteraNotas'].forEach(id=> f[id]=document.getElementById(id).value ); setDraft(DRAFT_CARTERA_KEY,f); }, 800); document.getElementById('carteraForm').oninput = saveDraftCar; } function closeCarteraModal(){ document.getElementById('carteraModal').classList.remove('active'); clearDraft(DRAFT_CARTERA_KEY); } function saveCartera(e){ e.preventDefault(); // Genera ID si está vacío const idField = document.getElementById('carteraId'); const ensuredId = (idField.value && idField.value.trim()) ? idField.value.trim() : getNextCarteraId(); idField.value = ensuredId; // refleja en el formulario const nuevo = { id: ensuredId, razon: document.getElementById('carteraRazon').value, contacto: document.getElementById('carteraContacto').value, tel: document.getElementById('carteraTel').value, email: document.getElementById('carteraEmail').value, direccion: document.getElementById('carteraDireccion').value, ciudad: document.getElementById('carteraCiudad').value, tipo: document.getElementById('carteraTipo').value, productos: document.getElementById('carteraProductos').value, volumen: document.getElementById('carteraVolumen').value, notas: document.getElementById('carteraNotas').value }; cartera.push(nuevo); saveArray(LS_CARTERA_KEY, cartera); (async()=>{ try{ const c=getSupa(); if (c){ await c.from('cartera').upsert([{...nuevo, updated_at:new Date().toISOString()}], { onConflict:'id' }); } }catch{} })(); // También agrega/actualiza en Seguimiento (clients) para que aparezca en otras hojas const nombre = nuevo.razon || nuevo.contacto || nuevo.id; if (nombre) { const existe = (clients||[]).some(c => c.name === nombre); if (!existe) { clients.push({ id: nuevo.id || ('CLI-' + Date.now().toString().slice(8)), name: nombre, phone: nuevo.tel || '', email: nuevo.email || '', origen: 'Cartera', fecha: new Date().toISOString().slice(0,10), estado: 'Nuevo', prob: 0, seg1: '', fechaSeg1: '', seg2: '', fechaSeg2: '', seg3: '', fechaSeg3: '', notas: nuevo.notas || '' }); saveArray(LS_KEY, clients); renderClients(); updateDashboard(); } } renderCartera(); // Refresca listas de clientes usadas en cotizaciones try { typeof refreshCustomerList === 'function' && refreshCustomerList(); } catch {} // Sincroniza contactos en cotizaciones que coincidan por nombre de cliente try { typeof window.bacmexSyncQuotesFromClients === 'function' && window.bacmexSyncQuotesFromClients(clients); } catch {} closeCarteraModal(); } async function deleteCartera(index){ const ok = await confirmModal('¿Eliminar este cliente de la cartera?'); if (ok){ const id = cartera[index]?.id; cartera.splice(index,1); saveArray(LS_CARTERA_KEY, cartera); try{ const c=getSupa(); if (c && id){ await c.from('cartera').delete().eq('id', id); } }catch{} renderCartera(); } } // Export/Import function exportCSV(){ let csv = 'ID,Nombre,Teléfono,Correo,Origen,Fecha,Estado,Probabilidad,Notas\n'; clients.forEach(c=>{ csv += `${c.id},${c.name},${c.phone},${c.email},${c.origen},${c.fecha},${c.estado},${c.prob}%,"${(c.notas||'').replace(/"/g,'\"')}"\n`; }); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bacmex_clientes_${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); isDirty = false; } function exportJSON(){ const data = { clients, cartera }; const blob = new Blob([JSON.stringify(data,null,2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'bacmex_backup.json'; a.click(); URL.revokeObjectURL(url); localStorage.setItem(LS_LAST_BACKUP, JSON.stringify({ at: Date.now(), data })); isDirty = false; } async function exportZIP(){ const zip = new JSZip(); zip.file('clients.json', JSON.stringify(clients,null,2)); zip.file('cartera.json', JSON.stringify(cartera,null,2)); const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'bacmex_backup.zip'; a.click(); URL.revokeObjectURL(url); localStorage.setItem(LS_LAST_BACKUP, JSON.stringify({ at: Date.now(), data: { clients, cartera } })); isDirty = false; } function validateData(obj){ return obj && Array.isArray(obj.clients) && Array.isArray(obj.cartera); } document.getElementById('importJson').addEventListener('change', (e)=>{ const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = ()=>{ try { const imp = JSON.parse(reader.result); if (!validateData(imp)) throw new Error('Estructura inválida'); clients = imp.clients; cartera = imp.cartera; saveArray(LS_KEY, clients); saveArray(LS_CARTERA_KEY, cartera); renderClients(); renderCartera(); updateDashboard(); isDirty = false; } catch(err){ alert('Importación inválida: ' + (err?.message||'')); } }; reader.readAsText(file); }); document.getElementById('importZip').addEventListener('change', async (e)=>{ const file = e.target.files?.[0]; if (!file) return; try { const zip = await JSZip.loadAsync(file); const clientsTxt = await zip.file('clients.json').async('string'); const carteraTxt = await zip.file('cartera.json').async('string'); const impClients = JSON.parse(clientsTxt); const impCartera = JSON.parse(carteraTxt); if (!Array.isArray(impClients) || !Array.isArray(impCartera)) throw new Error('Estructura inválida'); clients = impClients; cartera = impCartera; saveArray(LS_KEY, clients); saveArray(LS_CARTERA_KEY, cartera); renderClients(); renderCartera(); updateDashboard(); isDirty = false; } catch(err){ alert('ZIP inválido: ' + (err?.message||'')); } }); // Confirmación al salir y respaldo automático best-effort function triggerDownloadBackup(){ const blob = new Blob([JSON.stringify({ clients, cartera }, null, 2)], { type:'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bacmex-backup-${new Date().toISOString().replace(/[:.]/g,'-')}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); isDirty=false; } window.addEventListener('beforeunload', (e)=>{ try { localStorage.setItem(LS_LAST_BACKUP, JSON.stringify({ at: Date.now(), data: { clients, cartera } })); } catch {} if (isDirty) { e.preventDefault(); e.returnValue=''; return ''; } }); document.addEventListener('visibilitychange', ()=>{ if (document.visibilityState==='hidden' && isDirty) { try { triggerDownloadBackup(); } catch {} } }); // Inicialización renderClients(); renderCartera(); updateDashboard(); // ========================== // Sección: Cotizaciones (BACMEX) // ========================== (function initQuotesSection(){ const LS_QUOTES_KEY = 'bacmex_quotes_v1'; let quotes = loadArray(LS_QUOTES_KEY, []); const tabsEl = document.querySelector('.tabs'); const container = document.querySelector('.container'); const tabIndex = document.querySelectorAll('.tabs .tab').length; // nuevo índice // Crear pestaña const tabBtn = document.createElement('button'); tabBtn.className = 'tab'; tabBtn.textContent = '📄 Cotizaciones'; tabBtn.setAttribute('onclick', `showSheet(${tabIndex})`); tabsEl.appendChild(tabBtn); // Crear sheet const sheet = document.createElement('div'); sheet.className = 'sheet'; sheet.id = 'sheet' + tabIndex; sheet.innerHTML = `
📄 GESTIÓN DE COTIZACIONES
BALEROS Y COMPLEMENTOS DE MÉXICO - BACMEX
FOLIO CLIENTE FECHA VIGENCIA SUBTOTAL IMP. TOTAL ESTADO ACCIONES
`; container.appendChild(sheet); function calcTotals(items, taxRate=0.16, globalDiscount=0){ const subItems = items.reduce((s,it)=> s + Number(it.price||0)*Number(it.qty||0)*(1-Number(it.discount||0)), 0); const subtotal = subItems * (1 - Number(globalDiscount||0)); const tax = subtotal * taxRate; const total = subtotal + tax; return { subtotal, tax, total }; } function fmtMoney(n){ return new Intl.NumberFormat('es-MX', { style:'currency', currency:'MXN' }).format(Number(n||0)); } function saveQuotes(){ saveArray(LS_QUOTES_KEY, quotes); } // Exponer sincronización para actualizar contactos en cotizaciones desde clientes window.bacmexSyncQuotesFromClients = function syncFromClients(clientsArr){ try { let changed = false; quotes = quotes.map(q => { const c = (clientsArr||[]).find(x => x.name === q.customer); if (c) { const email = c.email || ''; const phone = c.phone || ''; if (q.customerEmail !== email || q.customerPhone !== phone) { changed = true; return { ...q, customerEmail: email, customerPhone: phone }; } } return q; }); if (changed) { saveQuotes(); renderQuotes(); } } catch {} }; function renderQuotes(){ const tb = sheet.querySelector('#q_tbody'); const statusVal = (sheet.querySelector('#q_filter_status')?.value||''); const fromVal = sheet.querySelector('#q_filter_from')?.value; const toVal = sheet.querySelector('#q_filter_to')?.value; const list = quotes.filter((q)=>{ const today = new Date(); today.setHours(0,0,0,0); const isExpired = q.validUntil ? (new Date(q.validUntil) < today && (q.status||'') !== 'Aprobada') : false; const soon = q.validUntil ? Math.ceil((new Date(q.validUntil).setHours(0,0,0,0) - today)/86400000) : null; const isSoon = soon !== null && soon >= 0 && soon <= 3 && (q.status||'') !== 'Aprobada' && !isExpired; if (statusVal) { if (statusVal === 'Vencida') { if (!isExpired) return false; } else if (statusVal === 'Por vencer') { if (!isSoon) return false; } else if ((q.status||'') !== statusVal) return false; } const d = q.date? new Date(q.date) : null; if (fromVal && d && d < new Date(fromVal)) return false; if (toVal && d && d > new Date(toVal+'T23:59:59')) return false; return true; }); const rows = list.map((q, idx)=>{ const t = calcTotals(q.items||[], 0.16, q.globalDiscount||0); const today = new Date(); today.setHours(0,0,0,0); const isExpired = q.validUntil ? (new Date(q.validUntil) < today && (q.status||'') !== 'Aprobada') : false; let statusLabel = (q.status||'Borrador'); const soon = q.validUntil ? Math.ceil((new Date(q.validUntil).setHours(0,0,0,0) - today)/86400000) : null; if (isExpired) statusLabel = 'Vencida'; else if (soon !== null && soon >= 0 && soon <= 3 && q.status !== 'Aprobada') statusLabel = `Por vencer (${soon} día${soon===1?'':'s'})`; let statusClass = 'estado-proceso'; if (statusLabel.startsWith('Por vencer')) statusClass = 'estado-proceso'; else if (statusLabel==='Vencida') statusClass = 'estado-perdido'; else if ((q.status||'')==='Aprobada') statusClass = 'estado-cerrado'; else if ((q.status||'')==='Rechazada') statusClass = 'estado-perdido'; return ` ${q.number} ${q.customer||''} ${(q.date? new Date(q.date).toLocaleDateString('es-MX'): '-') } ${q.validUntil? new Date(q.validUntil).toLocaleDateString('es-MX'): '-'} ${fmtMoney(t.subtotal)} ${fmtMoney(t.tax)} ${fmtMoney(t.total)} ${statusLabel}
`; }).join(''); tb.innerHTML = rows; } // Modal de edición/creación function openQuoteModal(q){ const modal = document.createElement('div'); modal.className = 'modal active'; modal.innerHTML = ` `; document.body.appendChild(modal); // Auto-selección de cliente desde cartera si está vacío const custInput = modal.querySelector('#q_customer'); const custSelect = modal.querySelector('#q_customer_select'); const custEmail = modal.querySelector('#q_customer_email'); const custPhone = modal.querySelector('#q_customer_phone'); function fillContactFromName(name){ const c = (clients||[]).find(x=> x.name === name); if (c){ if (custEmail) custEmail.value = c.email||''; if (custPhone) custPhone.value = c.phone||''; } } if (custInput && (!custInput.value || custInput.value.trim()==='') && (clients&&clients.length>0)) { custInput.value = clients[0].name; if (custSelect) custSelect.value = clients[0].name; fillContactFromName(clients[0].name); } if (custSelect) { custSelect.addEventListener('change', ()=>{ custInput.value = custSelect.value; fillContactFromName(custSelect.value); }); } if (custInput) { custInput.addEventListener('change', ()=> fillContactFromName(custInput.value)); } const items = (q?.items||[]).map(it=> ({ desc: it.desc||'', brand: it.brand||'', lead: it.lead||'', qty:Number(it.qty||1), price:Number(it.price||0), discount:Number(it.discount||0) })); const itemsEl = modal.querySelector('#q_items'); function renderItems(){ itemsEl.innerHTML=''; items.forEach((it, i)=>{ const row = document.createElement('div'); row.className = 'grid'; row.style.gridTemplateColumns = '3fr 1.5fr 1.5fr 1fr 1fr 1fr auto'; row.style.gap = '8px'; row.innerHTML = ` `; itemsEl.appendChild(row); }); } function updateTotals(){ const g = Number(modal.querySelector('#q_globalDiscount')?.value||0); const t = calcTotals(items, 0.16, g); modal.querySelector('#q_totals').textContent = `Subtotal ${fmtMoney(t.subtotal)} · Imp ${fmtMoney(t.tax)} · Total ${fmtMoney(t.total)}`; } renderItems(); updateTotals(); itemsEl.addEventListener('input', (e)=>{ const i = Number(e.target.getAttribute('data-i')); const f = e.target.getAttribute('data-f'); if (['desc','brand','lead'].includes(f)) items[i][f] = e.target.value; else items[i][f] = Number(e.target.value||0); updateTotals(); }); itemsEl.addEventListener('click', (e)=>{ if (e.target.hasAttribute('data-rm')) { const i = Number(e.target.getAttribute('data-rm')); items.splice(i,1); renderItems(); updateTotals(); } }); modal.querySelector('#q_add_item').onclick = ()=>{ items.push({ desc:'', qty:1, price:0, discount:0 }); renderItems(); updateTotals(); }; modal.querySelector('#q_globalDiscount').addEventListener('input', updateTotals); modal.querySelector('#q_close').onclick = ()=> modal.remove(); modal.querySelector('#q_cancel').onclick = ()=> { clearDraft(DRAFT_QUOTE_KEY); modal.remove(); }; modal.querySelector('#q_form').onsubmit = (ev)=>{ ev.preventDefault(); // Validaciones de ítems for (const it of items) { if (!(Number(it.qty) > 0)) { alert('Piezas debe ser > 0'); return; } if (!(Number(it.price) >= 0)) { alert('Precio unitario debe ser >= 0'); return; } const d = Number(it.discount); if (isNaN(d) || d < 0 || d > 1) { alert('Descuento debe estar entre 0 y 1'); return; } } // Normalización de tiempo de entrega (lead) function normalizeLead(s){ if (!s) return ''; const str = String(s).trim(); const range = str.match(/^(\d+)\s*[–-]\s*(\d+)/); // 3-5 if (range) { return `${range[1]}–${range[2]} días hábiles`; } const single = str.match(/(\d+)/); if (single) { return `${single[1]} días hábiles`; } return str; // deja tal cual si no se puede normalizar } const normItems = items.map(it => ({ ...it, lead: normalizeLead(it.lead) })); const newQ = { number: modal.querySelector('#q_number').value.trim(), customer: modal.querySelector('#q_customer').value.trim(), customerEmail: (modal.querySelector('#q_customer_email')?.value||'').trim(), customerPhone: (modal.querySelector('#q_customer_phone')?.value||'').trim(), date: new Date(modal.querySelector('#q_date').value).toISOString(), validUntil: (modal.querySelector('#q_validUntil').value? new Date(modal.querySelector('#q_validUntil').value).toISOString(): ''), status: modal.querySelector('#q_status').value, globalDiscount: Number(modal.querySelector('#q_globalDiscount')?.value||0), items: normItems }; if (q) { const idx = quotes.indexOf(q); quotes[idx] = newQ; } else { quotes.push(newQ); } saveQuotes(); renderQuotes(); modal.remove(); isDirty = true; }; } function openQuotePDF(q){ const t = calcTotals(q.items||[], 0.16, q.globalDiscount||0); const w = window.open('', '_blank'); const rows = (q.items||[]).map(function(it){ const disc = (Number(it.discount||0)*100).toFixed(0) + '%'; const totalItem = fmtMoney(Number(it.qty||0)*Number(it.price||0)*(1 - Number(it.discount||0))); return ''+ ''+ (it.desc||'') +''+ ''+ (it.brand||'') +''+ ''+ (it.lead||'') +''+ ''+ (it.qty||0) +''+ ''+ fmtMoney(it.price||0) +''+ ''+ disc +''+ ''+ totalItem +''+ ''; }).join(''); w.document.write(`${q.number}
Logo
BACMEX CRM • Cotización

${q.number}

${new Date().toLocaleString('es-MX')}
Cliente: ${q.customer}
Fecha: ${(q.date? new Date(q.date).toLocaleDateString('es-MX'): '-') }
${q.customerEmail? `
Correo: ${q.customerEmail}
`: ''}
Válida hasta: ${q.validUntil? new Date(q.validUntil).toLocaleDateString('es-MX') : '-'}
${q.customerPhone? `
Teléfono: ${q.customerPhone}
`: ''}
${rows}
DescripciónMarcaEntregaPiezasPrecio unitarioDescTotal
Subtotal:
${fmtMoney(t.subtotal)}
Impuestos:
${fmtMoney(t.tax)}
Total:
${fmtMoney(t.total)}
Términos y condiciones
Compartir WhatsApp Enviar Email