YI COMPUS
YI COMPUS 394 equipos

Con un lector de código de barras: apunta y dispara — el resultado aparece solo.
Sin lector: escribe el ID (ej. A1-N1-01) y presiona Enter.

No se encontró ese ID. Verifica el código e intenta de nuevo.
VENDIDA

Anaquel
Nivel
RAM
Almacenamiento
Buen estado
Con detalles
0
Total
0
Disponibles
0
Vendidas

Lleva la cuenta de lo que compras y pagas a cada proveedor — el saldo se calcula solo.

Nueva venta

Escanea, busca o agrega productos para cobrar

Tu venta

La caja está cerrada.

Semana actual
$0
Total vendido
0
Ventas
-
Mejor día
$0
Gastos
$0
Ganancia neta
Gastos de esta semana
Ventas por día (lunes a sábado)
Artículos más vendidos
Ventas por categoría
Respaldo automático en carpeta

Sin configurar

Cada vez que se registre una venta, gasto o movimiento de caja, se va a guardar automáticamente un archivo en esa carpeta. Así, aunque se borre el historial del navegador o se formatee la computadora, tienes tus datos a salvo en un archivo normal.

Respaldo manual

Descarga un archivo con absolutamente todo (inventario vendido, ventas, caja, gastos) a tu carpeta de Descargas, sin necesidad de configurar nada. Funciona en cualquier navegador.

Restaurar datos

Si perdiste tus datos (se borró el historial, cambiaste de computadora, etc.), elige aquí un archivo de respaldo que hayas descargado antes para recuperar todo tal como estaba.

'); doc.close(); setTimeout(() => { try { iframe.contentWindow.focus(); iframe.contentWindow.print(); } catch (e) {} setTimeout(() => { try { document.body.removeChild(iframe); } catch (e) {} }, 1500); }, 300); } document.getElementById('cotPreviewImprimirBtn').addEventListener('click', () => { imprimirHtmlEnIframeOculto(document.getElementById('cotizacionPrintContent').innerHTML, false); }); // Agrega, si existe, la imagen de la firma del cliente al PDF del ticket (centrada, ancho fijo). // Devuelve la nueva posicion "y" para seguir escribiendo debajo. function agregarFirmaAlPdf(doc, y, firmaDataUrl) { if (!firmaDataUrl) return y; try { const w = 42, h = 16; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.setTextColor(120); doc.text('Firma de conformidad del cliente', 40, y, { align: 'center' }); y += 2; doc.addImage(firmaDataUrl, 'PNG', 40 - w / 2, y, w, h); doc.setDrawColor(180); doc.setLineWidth(0.1); doc.line(40 - w / 2, y + h, 40 + w / 2, y + h); doc.setTextColor(0); y += h + 5; } catch (e) { /* si la firma no se pudo insertar, se sigue sin ella */ } return y; } function generateTicket(item, precioVenta, comprador, vendedor, pago) { const now = new Date(); const fecha = now.toLocaleDateString('es-MX') + ' ' + now.toLocaleTimeString('es-MX'); const folio = 'Folio: ' + item.id + '-' + now.getTime().toString().slice(-6); const filenameBase = 'Ticket_' + item.id + '_' + now.toISOString().slice(0,10); setLastReceipt({ folio, fecha, articulo: item.marca + ' ' + item.modelo, lineas: [ 'ID: ' + item.id, 'Ubicación: Anaquel ' + item.anaquel + ' / Nivel ' + item.nivel, 'Procesador: ' + (item.procesador || 'N/D'), 'RAM: ' + (item.ram || 'N/D') + ' Almac.: ' + (item.almacenamiento || 'N/D'), ].concat(item.touch ? ['Pantalla: ' + item.touch] : []).concat(item.gpu ? ['Gráfica: ' + item.gpu] : []), precio: precioVenta, pago, comprador, vendedor, }); if (typeof window.jspdf === 'undefined') { downloadTextFallback(filenameBase + '.txt', buildTicketText(item, precioVenta, comprador, vendedor, folio, fecha, pago)); return; } try { const { jsPDF } = window.jspdf; const doc = new jsPDF({ unit: 'mm', format: getTipoImpresora() === 'normal' ? 'letter' : [80, 225] }); let y = 8; try { const logoW = 40, logoH = logoW * 157 / 400; doc.addImage('data:image/png;base64,' + LOGO_B64, 'PNG', 40 - logoW/2, y, logoW, logoH); y += logoH + 4; } catch (e) { /* logo failed to embed, continue without it */ } doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.text(STORE_NAME, 40, y, { align: 'center' }); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.text(STORE_ADDRESS, 40, y, { align: 'center', maxWidth: 68 }); y += 3.2; doc.text('Tel. ' + STORE_PHONE, 40, y, { align: 'center' }); y += 4; doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.text('TICKET DE VENTA', 40, y, { align: 'center' }); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.text(folio, 40, y, { align: 'center' }); y += 4; doc.text('Fecha y hora: ' + fecha, 40, y, { align: 'center' }); y += 6; doc.setLineWidth(0.2); doc.line(6, y, 74, y); y += 5; doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.text(item.marca + ' ' + item.modelo, 6, y); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); const lines = [ 'ID: ' + item.id, 'Ubicación: Anaquel ' + item.anaquel + ' / Nivel ' + item.nivel, 'Procesador: ' + (item.procesador || 'N/D'), 'RAM: ' + (item.ram || 'N/D') + ' Almac.: ' + (item.almacenamiento || 'N/D'), ]; if (item.touch) lines.push('Pantalla: ' + item.touch); if (item.gpu) lines.push('Gráfica: ' + item.gpu); lines.forEach(l => { doc.text(l, 6, y); y += 4.5; }); y += 2; doc.line(6, y, 74, y); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.text('Precio: ' + money(precioVenta), 40, y, { align: 'center' }); y += 7; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.text('Método de pago: ' + pago.metodo, 6, y); y += 4.5; if (pago.metodo === 'Efectivo') { doc.text('Recibido: ' + money(pago.recibido), 6, y); y += 4.5; doc.text('Cambio: ' + money(pago.cambio), 6, y); y += 4.5; } if (comprador) { doc.text('Comprador: ' + comprador, 6, y); y += 5; } doc.text('Vendedor: ' + vendedor, 6, y); y += 5; y = agregarFirmaAlPdf(doc, y, pago.firma); y += 2; doc.line(6, y, 74, y); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(8); doc.text('Garantía de 30 días', 40, y, { align: 'center' }); y += 4.5; doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.text('No hay reembolso de dinero,', 40, y, { align: 'center' }); y += 4; doc.text('solo cambio de mercancía.', 40, y, { align: 'center' }); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.text('¿Necesitas factura?', 40, y, { align: 'center' }); y += 3.8; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.text('WhatsApp 000-000-0000 con foto del', 40, y, { align: 'center' }); y += 3.5; doc.text('ticket y tu constancia de situación', 40, y, { align: 'center' }); y += 3.5; doc.text('fiscal (o tus datos fiscales).', 40, y, { align: 'center' }); y += 6; doc.setFontSize(7); doc.setTextColor(120); doc.text('Gracias por su compra', 40, y, { align: 'center' }); mostrarPreviaImpresion(doc, 'Vista previa del recibo', filenameBase + '.pdf'); } catch (e) { downloadTextFallback(filenameBase + '.txt', buildTicketText(item, precioVenta, comprador, vendedor, folio, fecha, pago)); } } function buildCustomTicketText(nombre, caracteristicas, precioVenta, comprador, vendedor, folio, fecha, pago) { return [ STORE_NAME, STORE_ADDRESS, 'Tel. ' + STORE_PHONE, 'TICKET DE VENTA', folio, 'Fecha y hora: ' + fecha, '', 'Artículo: ' + nombre, caracteristicas ? ('Características: ' + caracteristicas) : '', '', 'Precio de venta: ' + money(precioVenta), 'Método de pago: ' + pago.metodo, pago.metodo === 'Efectivo' ? ('Recibido: ' + money(pago.recibido)) : '', pago.metodo === 'Efectivo' ? ('Cambio: ' + money(pago.cambio)) : '', comprador ? ('Comprador: ' + comprador) : '', 'Vendedor: ' + vendedor, '', 'Garantía de 30 días.', 'No hay reembolso de dinero, solo cambio de mercancía.', '', 'Para factura: manda WhatsApp al 000-000-0000', 'con foto del ticket y tu constancia de', 'situación fiscal (o tus datos fiscales).', ].filter(Boolean).join('\n'); } async function getListFromStorage(key) { if (hasSupabase) { try { const value = await supaGet(key); return Array.isArray(value) ? value : []; } catch (e) { /* fall through */ } } if (hasSharedStorage) { try { const result = await window.storage.get(key, true); if (!result) return []; const parsed = JSON.parse(result.value); return Array.isArray(parsed) ? parsed : []; } catch (e) { /* fall through to local */ } } try { const parsed = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } } async function setListToStorage(key, list) { if (hasSupabase) { try { await supaSet(key, list); scheduleAutoBackup(); return; } catch (e) {} } if (hasSharedStorage) { try { await window.storage.set(key, JSON.stringify(list), true); scheduleAutoBackup(); return; } catch (e) {} } try { localStorage.setItem(key, JSON.stringify(list)); } catch (e) {} scheduleAutoBackup(); } // --- Agregar mercancia nueva al inventario (disponible para todos los usuarios) --- const EXTRA_INV_KEY = 'inventario_extra'; // Recuerda el ultimo anaquel/nivel usado al agregar mercancia, para no tener que // volver a escribirlo si vas a agregar varias piezas seguidas al mismo lugar. let ultimaUbicacion = { anaquel: localStorage.getItem('ultimo_anaquel') || '', nivel: localStorage.getItem('ultimo_nivel') || '', }; function guardarUltimaUbicacion(anaquel, nivel) { ultimaUbicacion = { anaquel, nivel }; try { localStorage.setItem('ultimo_anaquel', anaquel); localStorage.setItem('ultimo_nivel', nivel); } catch (e) {} } async function mergeExtraInventory() { let extras = []; try { extras = await getListFromStorage(EXTRA_INV_KEY); } catch (e) { extras = []; } extras.forEach(item => { try { if (!item || !item.id) return; const idU = String(item.id).toUpperCase(); if (!INDEX[idU]) { DATA.push(item); INDEX[idU] = item; } } catch (e) { /* ignora un registro corrupto sin tumbar toda la carga */ } }); } // --- Eliminar producto del inventario (solo administrador) --- const ELIMINADOS_KEY = 'inventario_eliminados'; async function getEliminados() { try { return await getListFromStorage(ELIMINADOS_KEY); } catch (e) { return []; } } // Quita del DATA/INDEX en memoria cualquier producto que ya haya sido marcado como eliminado // (se llama en cada carga de la app, para que un producto borrado no reaparezca). async function aplicarEliminadosDelInventario() { const eliminados = await getEliminados(); if (!Array.isArray(eliminados) || eliminados.length === 0) return; const idsEliminados = new Set(eliminados.map(e => String((e && e.id) || '').toUpperCase()).filter(Boolean)); for (let i = DATA.length - 1; i >= 0; i--) { try { const idU = String(DATA[i].id || '').toUpperCase(); if (idsEliminados.has(idU)) { DATA.splice(i, 1); delete INDEX[idU]; } } catch (e) { /* ignora un registro corrupto */ } } } // --- Correcciones a productos existentes (para poder editar una etiqueta mal hecha) --- const CORRECCIONES_KEY = 'inventario_correcciones'; async function getCorrecciones() { try { return await getListFromStorage(CORRECCIONES_KEY); } catch (e) { return []; } } // Aplica correcciones guardadas sobre los objetos ya cargados en DATA/INDEX // (se corre despues de mergeExtraInventory, para que tambien corrija productos del catalogo original). async function aplicarCorreccionesDelInventario() { const correcciones = await getCorrecciones(); if (!Array.isArray(correcciones)) return; correcciones.forEach(c => { try { const idU = String((c && c.id) || '').toUpperCase(); if (!idU) return; const item = INDEX[idU]; if (item) Object.assign(item, c); } catch (e) { /* ignora un registro corrupto */ } }); } async function guardarCorreccion(item) { const correcciones = await getCorrecciones(); const idU = item.id.toUpperCase(); const idx = correcciones.findIndex(c => (c.id || '').toUpperCase() === idU); const snapshot = Object.assign({}, item); if (idx === -1) correcciones.push(snapshot); else correcciones[idx] = snapshot; try { await setListToStorage(CORRECCIONES_KEY, correcciones); } catch (e) {} } // --- Estado del equipo (pendiente / en reparacion / listo / con falla), checklist de // diagnostico, refacciones usadas, y apartado para cliente — todo se guarda como parte // del producto mismo (via guardarCorreccion), igual que cualquier otra edicion. const ESTADOS_EQUIPO = { pendiente: { etiqueta: 'Pendiente de revisar', color: '#f0b45a', icono: 'ti-clock' }, en_reparacion: { etiqueta: 'En reparación', color: '#4ea1f0', icono: 'ti-tool' }, listo: { etiqueta: 'Listo', color: '#35c3f0', icono: 'ti-circle-check' }, con_falla: { etiqueta: 'Con falla', color: '#e2504a', icono: 'ti-alert-triangle' }, }; const CHECKLIST_DIAGNOSTICO = [ { clave: 'enciende', etiqueta: 'Enciende correctamente' }, { clave: 'bateria', etiqueta: 'Batería carga y sostiene' }, { clave: 'pantalla', etiqueta: 'Pantalla sin manchas/líneas' }, { clave: 'teclado', etiqueta: 'Teclado funciona completo' }, { clave: 'wifi', etiqueta: 'Wifi conecta bien' }, { clave: 'camara', etiqueta: 'Cámara funciona' }, { clave: 'puertos', etiqueta: 'Puertos USB / cargador ok' }, { clave: 'bocinas', etiqueta: 'Bocinas / audio ok' }, ]; async function guardarApartado(item, info) { item.apartado = info; await guardarCorreccion(item); } // --- Deshacer la ultima accion (venta o eliminacion de producto) --- // Se guarda como una PILA de hasta 5 acciones (no solo la ultima), y se persiste en // localStorage de este dispositivo para que sobreviva a un refresh de la pagina. // Antes esto vivia solo en memoria y se perdia al recargar. const HISTORIAL_ACCIONES_KEY = 'historial_acciones_local'; const HISTORIAL_ACCIONES_MAX = 5; let historialAcciones = []; function cargarHistorialAcciones() { try { const guardado = localStorage.getItem(HISTORIAL_ACCIONES_KEY); historialAcciones = guardado ? JSON.parse(guardado) : []; if (!Array.isArray(historialAcciones)) historialAcciones = []; } catch (e) { historialAcciones = []; } } function guardarHistorialAcciones() { try { localStorage.setItem(HISTORIAL_ACCIONES_KEY, JSON.stringify(historialAcciones)); } catch (e) {} } cargarHistorialAcciones(); // --- Registro de auditoria: quien hizo que y cuando --- async function registrarAuditoria(accion, detalle) { try { const log = await getListFromStorage('auditoria'); log.push({ fecha: new Date().toISOString(), quien: currentSession ? currentSession.nombre : 'N/D', accion, detalle, }); // se limita a los ultimos 1000 registros para no crecer indefinidamente const recortado = log.length > 1000 ? log.slice(log.length - 1000) : log; await setListToStorage('auditoria', recortado); } catch (e) {} } function registrarUltimaAccion(accion) { historialAcciones.push(Object.assign({ fecha: new Date().toISOString() }, accion)); if (historialAcciones.length > HISTORIAL_ACCIONES_MAX) historialAcciones.shift(); guardarHistorialAcciones(); actualizarBotonDeshacer(); } function actualizarBotonDeshacer() { const btn = document.getElementById('deshacerBtn'); if (!btn) return; const ultimaAccion = historialAcciones[historialAcciones.length - 1]; if (ultimaAccion) { btn.style.display = 'flex'; const extra = historialAcciones.length > 1 ? ' (+' + (historialAcciones.length - 1) + ' más)' : ''; btn.querySelector('span:last-child').textContent = 'Deshacer: ' + ultimaAccion.descripcion + extra; } else { btn.style.display = 'none'; } } async function deshacerUltimaAccion() { const ultimaAccion = historialAcciones[historialAcciones.length - 1]; if (!ultimaAccion) { alertBonito('No hay ninguna acción reciente para deshacer.'); return; } if (!(await confirmarBonito(ultimaAccion.descripcion, '¿Deshacer esto?'))) return; if (ultimaAccion.tipo === 'venta') { for (const saleId of ultimaAccion.saleIds) { await cancelarVentaInventario(saleId, true); } updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); } else if (ultimaAccion.tipo === 'venta_carrito') { // ventas del carrito pueden mezclar productos de inventario y productos manuales — // cada quien se cancela con la funcion que le corresponde for (const saleId of (ultimaAccion.saleIdsInventario || [])) { await cancelarVentaInventario(saleId, true); } for (const saleId of (ultimaAccion.saleIdsExternas || [])) { await cancelarVentaExterna(saleId, true); } updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); } else if (ultimaAccion.tipo === 'venta_externa') { for (const saleId of ultimaAccion.saleIds) { await cancelarVentaExterna(saleId, true); } } else if (ultimaAccion.tipo === 'eliminacion') { const item = ultimaAccion.item; const idU = item.id.toUpperCase(); if (!INDEX[idU]) { DATA.push(item); INDEX[idU] = item; } try { const eliminados = await getEliminados(); const nuevosEliminados = eliminados.filter(e => (e.id || '').toUpperCase() !== idU); await setListToStorage(ELIMINADOS_KEY, nuevosEliminados); } catch (e) {} if (item.fechaAgregado) { try { const extras = await getListFromStorage(EXTRA_INV_KEY); if (!extras.some(e => (e.id || '').toUpperCase() === idU)) { extras.push(item); await setListToStorage(EXTRA_INV_KEY, extras); } } catch (e) {} } updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); } else if (ultimaAccion.tipo === 'eliminacion_masiva') { ultimaAccion.items.forEach(item => { const idU = item.id.toUpperCase(); const eraVendido = item._soldAlEliminar; const eraAgotado = item._agotadoAlEliminar; delete item._soldAlEliminar; delete item._agotadoAlEliminar; if (!INDEX[idU]) { DATA.push(item); INDEX[idU] = item; } if (eraVendido) { soldSet.add(item.id); } else { soldSet.delete(item.id); } if (eraAgotado) { agotadoSet.add(item.id); } else { agotadoSet.delete(item.id); } }); await saveSoldSet(); await saveAgotadoSet(); try { const eliminados = await getEliminados(); const idsRestaurados = new Set(ultimaAccion.items.map(it => it.id.toUpperCase())); const nuevosEliminados = eliminados.filter(e => !idsRestaurados.has((e.id || '').toUpperCase())); await setListToStorage(ELIMINADOS_KEY, nuevosEliminados); } catch (e) {} try { const extras = await getListFromStorage(EXTRA_INV_KEY); const idsExtra = new Set(extras.map(e => (e.id || '').toUpperCase())); let cambiado = false; ultimaAccion.items.forEach(item => { if (item.fechaAgregado && !idsExtra.has(item.id.toUpperCase())) { extras.push(item); cambiado = true; } }); if (cambiado) await setListToStorage(EXTRA_INV_KEY, extras); } catch (e) {} updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); } registrarAuditoria('Deshizo acción', ultimaAccion.descripcion); historialAcciones.pop(); guardarHistorialAcciones(); actualizarBotonDeshacer(); alertBonito('Listo, se deshizo la última acción.'); } // --- Conteo fisico de inventario: escanear todo lo que hay, y marcar/eliminar lo que no aparezca --- // El progreso del conteo (quien lo empezo y que se ha escaneado) se guarda en linea para que // cualquier dispositivo que tambien este contando sume a la misma lista compartida. const CONTEO_KEY = 'conteo_activo'; async function getConteoRemoto() { if (hasSupabase) { try { const value = await supaGet(CONTEO_KEY); return value || null; } catch (e) { /* fall through */ } } if (hasSharedStorage) { try { const result = await window.storage.get(CONTEO_KEY, true); if (!result) return null; return JSON.parse(result.value); } catch (e) { /* fall through */ } } try { const raw = localStorage.getItem(CONTEO_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } async function setConteoRemoto(obj) { if (hasSupabase) { try { await supaSet(CONTEO_KEY, obj); return; } catch (e) {} } if (hasSharedStorage) { try { await window.storage.set(CONTEO_KEY, JSON.stringify(obj), true); return; } catch (e) {} } try { localStorage.setItem(CONTEO_KEY, JSON.stringify(obj)); } catch (e) { avisarFalloGuardado(); } } // --- Salida/regreso de comer de los técnicos: se guarda en línea quién salió, a qué hora, // y cuánto duró, para que el administrador pueda revisarlo (los técnicos no lo ven). --- const DESCANSO_ACTIVO_KEY = 'descanso_activo'; // { NOMBRE: { inicio: iso } } de quienes están afuera ahorita const DESCANSOS_KEY = 'descansos_tecnicos'; // historial: { quien, inicio, fin, duracionMin } async function getDescansoActivo() { if (hasSupabase) { try { const v = await supaGet(DESCANSO_ACTIVO_KEY); return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {}; } catch (e) {} } if (hasSharedStorage) { try { const r = await window.storage.get(DESCANSO_ACTIVO_KEY, true); if (!r) return {}; const parsed = JSON.parse(r.value); return (parsed && typeof parsed === 'object') ? parsed : {}; } catch (e) {} } try { return JSON.parse(localStorage.getItem(DESCANSO_ACTIVO_KEY) || '{}'); } catch (e) { return {}; } } async function setDescansoActivo(obj) { if (hasSupabase) { try { await supaSet(DESCANSO_ACTIVO_KEY, obj); return; } catch (e) {} } if (hasSharedStorage) { try { await window.storage.set(DESCANSO_ACTIVO_KEY, JSON.stringify(obj), true); return; } catch (e) {} } try { localStorage.setItem(DESCANSO_ACTIVO_KEY, JSON.stringify(obj)); } catch (e) {} } async function actualizarBotonDescanso() { if (!currentSession || currentSession.rol !== 'tecnico') return; const btn = document.getElementById('descansoBtn'); if (!btn) return; let activos = {}; try { activos = await getDescansoActivo(); } catch (e) {} const miEntrada = activos[currentSession.nombre]; if (miEntrada) { btn.classList.add('activo'); btn.innerHTML = 'Regresé de comer'; } else { btn.classList.remove('activo'); btn.innerHTML = 'Salir a comer'; } } document.getElementById('descansoBtn').addEventListener('click', async () => { if (!currentSession || currentSession.rol !== 'tecnico') return; const btn = document.getElementById('descansoBtn'); btn.disabled = true; try { const activos = await getDescansoActivo(); const miEntrada = activos[currentSession.nombre]; if (miEntrada) { // esta regresando de comer: calcula cuanto duro y lo guarda en el historial const inicio = new Date(miEntrada.inicio); const fin = new Date(); const duracionMin = Math.max(0, Math.round((fin - inicio) / 60000)); delete activos[currentSession.nombre]; await setDescansoActivo(activos); const historial = await getListFromStorage(DESCANSOS_KEY); historial.push({ quien: currentSession.nombre, inicio: inicio.toISOString(), fin: fin.toISOString(), duracionMin }); await setListToStorage(DESCANSOS_KEY, historial); alertBonito('Bienvenido de vuelta. Estuviste fuera ' + duracionMin + ' minuto(s).'); } else { // esta saliendo a comer activos[currentSession.nombre] = { inicio: new Date().toISOString() }; await setDescansoActivo(activos); alertBonito('Registrado. Que aproveche — toca este mismo botón cuando regreses.'); } await actualizarBotonDescanso(); } catch (e) { alertBonito('No se pudo registrar. Revisa tu conexión e intenta de nuevo.'); } finally { btn.disabled = false; } }); // --- Reporte de técnicos (solo admin): cuántos equipos registró cada técnico esta semana // (lunes a sábado) con fecha y hora de cada registro, y cuánto tiempo salieron a comer. --- function obtenerRangoSemanaActual() { const hoy = new Date(); const dia = hoy.getDay(); // 0=domingo, 1=lunes, ... 6=sabado const diffLunes = dia === 0 ? -6 : 1 - dia; const lunes = new Date(hoy); lunes.setHours(0, 0, 0, 0); lunes.setDate(hoy.getDate() + diffLunes); const sabado = new Date(lunes); sabado.setDate(lunes.getDate() + 5); sabado.setHours(23, 59, 59, 999); return { inicio: lunes, fin: sabado }; } function filaReporteTecnico(texto1, texto2) { return '
' + texto1 + '
' + texto2 + '
'; } async function renderReporteTecnicos() { const cont = document.getElementById('tecnicosReporteContenido'); const rangoEl = document.getElementById('tecnicosReporteRango'); cont.innerHTML = '

Cargando...

'; const { inicio, fin } = obtenerRangoSemanaActual(); const fmt = (d) => d.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }); rangoEl.textContent = 'Semana del ' + fmt(inicio) + ' al ' + fmt(fin) + ' (lunes a sábado)'; let registros = [], descansos = []; try { registros = await getListFromStorage('registro_tecnicos'); } catch (e) {} try { descansos = await getListFromStorage(DESCANSOS_KEY); } catch (e) {} const regSemana = registros.filter(r => r && r.fecha && new Date(r.fecha) >= inicio && new Date(r.fecha) <= fin); const descSemana = descansos.filter(d => d && d.inicio && new Date(d.inicio) >= inicio && new Date(d.inicio) <= fin); const nombres = Object.keys(TECNICO_PINS); let html = ''; nombres.forEach(nombre => { const misRegistros = regSemana.filter(r => r.quien === nombre).sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); const totalEquipos = misRegistros.reduce((s, r) => s + (r.cantidad || 0), 0); const misDescansos = descSemana.filter(d => d.quien === nombre).sort((a, b) => new Date(b.inicio) - new Date(a.inicio)); const totalMinComida = misDescansos.reduce((s, d) => s + (d.duracionMin || 0), 0); html += '
'; html += '
' + nombre + '' + '' + totalEquipos + ' equipo' + (totalEquipos === 1 ? '' : 's') + '
'; if (misRegistros.length === 0) { html += '
Sin registros esta semana
'; } else { html += misRegistros.map(r => { const fechaStr = new Date(r.fecha).toLocaleString('es-MX', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const origenTag = r.origen === 'USB' ? ' USB automático' : ' Manual'; const productosHtml = Array.isArray(r.productos) && r.productos.length > 0 ? '
' + r.productos.map(p => '' + (p.nombre || 'Producto') + (p.id ? ' ' + p.id + '' : '') + '' ).join('') + '
' : '
' + (r.cantidad || 0) + ' equipo(s) — sin detalle (registro anterior)
'; return '
' + '
' + fechaStr + ' · ' + origenTag + '
' + productosHtml + '
'; }).join(''); } if (misDescansos.length > 0) { html += '
🍽️ Comidas: ' + totalMinComida + ' min en total esta semana
'; html += misDescansos.map(d => { const fechaStr = new Date(d.inicio).toLocaleString('es-MX', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); return '
' + fechaStr + '
' + '
' + d.duracionMin + ' min
'; }).join(''); } html += '
'; }); cont.innerHTML = html; } document.getElementById('tecnicosReporteBtn').addEventListener('click', () => { document.getElementById('tecnicosReporteModal').style.display = 'flex'; renderReporteTecnicos(); }); document.getElementById('tecnicosReporteCerrarBtn').addEventListener('click', () => { document.getElementById('tecnicosReporteModal').style.display = 'none'; }); // --- Reporte del checador (entradas, salidas, tarde/faltas, lunes a sabado) --- const EMPLEADOS_CHECADOR_DEFAULT = ['YAHIR', 'DIEGO', 'LUIS', 'EMILI', 'XOLOGORDO', 'JOSUÉ', 'SURCHI', 'OMAR', 'ALONSO']; const HORARIO_CHECADOR = { 1: { inicio: '09:00', fin: '18:00' }, 2: { inicio: '09:00', fin: '18:00' }, 3: { inicio: '09:00', fin: '18:00' }, 4: { inicio: '09:00', fin: '18:00' }, 5: { inicio: '09:00', fin: '18:00' }, 6: { inicio: '09:00', fin: '15:00' }, }; function fechaLocalISOChecador(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } function formatoHoraChecador(d) { return d.toLocaleTimeString('es-MX', { hour: 'numeric', minute: '2-digit' }); } function inicioDeSemanaChecador(fecha) { const d = new Date(fecha); const dia = d.getDay(); const offset = dia === 0 ? -6 : (1 - dia); d.setDate(d.getDate() + offset); d.setHours(0, 0, 0, 0); return d; } function textoIncidenteChecador(r) { if (r.tarde) return (r.motivoTarde || 'Tarde') + ' (+' + r.minutosTarde + ' min)'; if (r.temprano) return 'Salió antes (-' + r.minutosTemprano + ' min)'; return ''; } // Arma el texto de notas de un dia: salidas/regresos de comer y Seven, mas // cualquier tardanza, en orden cronologico, para que se vea toda la actividad. function eventosNotaTextoChecador(delDia) { const ordenados = delDia.slice().sort((a, b) => new Date(a.fecha) - new Date(b.fecha)); return ordenados.map(r => { let etiqueta = null; if (r.tipo === 'salida_comer') etiqueta = 'Salida a comer'; else if (r.tipo === 'salida_seven') etiqueta = 'Salida a Seven'; else if (r.tipo === 'entrada' && r.contexto === 'regreso_comer') etiqueta = 'Regreso de comer'; else if (r.tipo === 'entrada' && r.contexto === 'regreso_seven') etiqueta = 'Regreso de Seven'; else if (r.tipo === 'entrada' && r.contexto === 'llegada' && r.tarde) etiqueta = 'Llegó tarde'; else if (r.tipo === 'salida_final' && r.temprano) etiqueta = 'Salió antes'; if (!etiqueta) return null; const hora = formatoHoraChecador(new Date(r.fecha)); const marca = r.tarde ? ' (+' + r.minutosTarde + 'm)' : (r.temprano ? ' (-' + r.minutosTemprano + 'm)' : ''); return etiqueta + ' ' + hora + marca; }).filter(Boolean).join(' · '); } function generarOpcionesSemanaChecador() { const select = document.getElementById('checadorReporteSemanaSelect'); select.innerHTML = ''; const hoy = new Date(); for (let i = 0; i < 8; i++) { const base = new Date(hoy); base.setDate(base.getDate() - i * 7); const lunes = inicioDeSemanaChecador(base); const sabado = new Date(lunes); sabado.setDate(sabado.getDate() + 5); const opt = document.createElement('option'); opt.value = fechaLocalISOChecador(lunes); opt.textContent = lunes.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }) + ' – ' + sabado.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }) + (i === 0 ? ' (esta semana)' : ''); select.appendChild(opt); } } async function renderReporteChecador() { const lunesIso = document.getElementById('checadorReporteSemanaSelect').value; const lunes = new Date(lunesIso + 'T00:00:00'); const dias = []; for (let i = 0; i < 6; i++) { const d = new Date(lunes); d.setDate(d.getDate() + i); dias.push(d); } let registros = [], empleadosGuardados = []; try { registros = await getListFromStorage('checador_registros'); } catch (e) {} try { empleadosGuardados = await getListFromStorage('checador_empleados'); } catch (e) {} const nombres = (Array.isArray(empleadosGuardados) && empleadosGuardados.length > 0) ? empleadosGuardados.map(e => e.nombre) : EMPLEADOS_CHECADOR_DEFAULT; const cont = document.getElementById('checadorReporteContenido'); cont.innerHTML = ''; // resumen rapido de incidentes de la semana const incidentes = []; nombres.forEach(nombre => { dias.forEach(dia => { const diaIso = fechaLocalISOChecador(dia); const delDia = registros.filter(r => r.quien === nombre && r.fechaDia === diaIso); const nombreDia = dia.toLocaleDateString('es-MX', { weekday: 'long', day: 'numeric', month: 'short' }); delDia.filter(r => r.tarde || r.temprano).forEach(r => { incidentes.push(nombre + ' — ' + textoIncidenteChecador(r) + ' el ' + nombreDia + ' (' + formatoHoraChecador(new Date(r.fecha)) + ')'); }); const esFuturo = dia > new Date(); const llegada = delDia.find(r => r.tipo === 'entrada' && r.contexto === 'llegada'); if (!esFuturo && !llegada && HORARIO_CHECADOR[dia.getDay()]) { incidentes.push(nombre + ' — Faltó el ' + nombreDia); } // Turno abierto: marcó entrada pero nunca marcó salida final. Se avisa solo // despues de que ya paso la hora de salida (con 1h de margen), para no marcar // como "abierto" un turno que sigue en curso normalmente. const horarioDia = HORARIO_CHECADOR[dia.getDay()]; if (!esFuturo && llegada && horarioDia) { const salidaFinal = delDia.find(r => r.tipo === 'salida_final'); if (!salidaFinal) { const [hh, mm] = horarioDia.fin.split(':').map(Number); const finConMargen = new Date(dia); finConMargen.setHours(hh, mm + 60, 0, 0); if (new Date() > finConMargen) { incidentes.push(nombre + ' — Turno abierto (marcó entrada pero no salida) el ' + nombreDia); } } } }); }); const resumenEl = document.getElementById('checadorReporteResumen'); if (incidentes.length === 0) { resumenEl.innerHTML = '

✔ Nadie llegó tarde, faltó, ni salió antes esta semana.

'; } else { resumenEl.innerHTML = '

⚠ ' + incidentes.length + ' incidente(s) esta semana:

' + ''; } nombres.forEach(nombre => { const card = document.createElement('div'); card.className = 'reporte-tec-card'; const wrap = document.createElement('div'); wrap.className = 'checador-scroll'; const tabla = document.createElement('table'); tabla.className = 'checador-tabla'; tabla.innerHTML = 'DíaEntradaSalidaNotas'; let totalMinutosSemana = 0; dias.forEach(dia => { const diaIso = fechaLocalISOChecador(dia); const delDia = registros.filter(r => r.quien === nombre && r.fechaDia === diaIso); const llegada = delDia.find(r => r.tipo === 'entrada' && r.contexto === 'llegada'); const salidasFinal = delDia.filter(r => r.tipo === 'salida_final'); const salidaFinal = salidasFinal.length ? salidasFinal[salidasFinal.length - 1] : null; const nombreDia = dia.toLocaleDateString('es-MX', { weekday: 'short', day: 'numeric', month: 'short' }); if (llegada && salidaFinal) { totalMinutosSemana += Math.max(0, Math.round((new Date(salidaFinal.fecha) - new Date(llegada.fecha)) / 60000)); } const esFuturo = dia > new Date(); const row = document.createElement('tr'); const tdDia = '' + nombreDia + ''; const tdEntrada = llegada ? '' + formatoHoraChecador(new Date(llegada.fecha)) + '' : '' + (esFuturo ? '—' : 'Faltó') + ''; const tdSalida = salidaFinal ? '' + formatoHoraChecador(new Date(salidaFinal.fecha)) + '' : '' + (esFuturo || !llegada ? '—' : 'Sin registrar') + ''; const notas = eventosNotaTextoChecador(delDia); row.innerHTML = tdDia + tdEntrada + tdSalida + '' + escapeHtmlCell(notas) + ''; tabla.appendChild(row); }); const horas = Math.floor(totalMinutosSemana / 60), minutos = totalMinutosSemana % 60; card.innerHTML = '
' + escapeHtmlCell(nombre) + '' + '' + horas + 'h ' + minutos + 'min esta semana
'; wrap.appendChild(tabla); card.appendChild(wrap); cont.appendChild(card); }); } document.getElementById('checadorReporteBtn').addEventListener('click', () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede ver el reporte del checador.'); return; } generarOpcionesSemanaChecador(); renderReporteChecador(); document.getElementById('checadorReporteModal').style.display = 'flex'; }); document.getElementById('checadorReporteSemanaSelect').addEventListener('change', renderReporteChecador); document.getElementById('checadorReporteCerrarBtn').addEventListener('click', () => { document.getElementById('checadorReporteModal').style.display = 'none'; }); // --- Etiquetas sobrantes: por si una etiqueta impresa se quedo pegada a un producto // que ya se marco como agotado (por error de conteo, por ejemplo). Se puede revisar // varias a la vez, y decidir por cada una si se reactiva o se genera etiqueta nueva. let listaSobrantes = []; function renderSobrantes() { const cont = document.getElementById('sobrantesLista'); if (listaSobrantes.length === 0) { cont.innerHTML = '

Todavía no has agregado ninguna etiqueta a revisar.

'; return; } cont.innerHTML = listaSobrantes.map((s, i) => { const item = s.item; const coincide = item && agotadoSet.has(item.id); const claseFila = !item ? 'no-coincide' : (coincide ? 'coincide' : 'no-coincide'); const nombre = item ? (item.marca + ' ' + item.modelo) : 'No se encontró ningún producto con ese ID'; const estado = !item ? '✘ Ese ID no existe en tu catálogo' : coincide ? '✔ Está marcado como agotado — sí puedes reactivarlo' : '✘ Este producto NO está marcado como agotado (ya está disponible o vendido)'; return '
' + '
' + s.id + '
' + '
' + nombre + '
' + '
' + estado + '
' + (item ? '
' + '' + '' + '
' : '') + '
'; }).join(''); cont.querySelectorAll('.sr2-quitar').forEach(btn => btn.addEventListener('click', () => { listaSobrantes.splice(parseInt(btn.dataset.idx, 10), 1); renderSobrantes(); })); cont.querySelectorAll('.sr2-reactivar').forEach(btn => btn.addEventListener('click', async (e) => { const idx = parseInt(btn.dataset.idx, 10); const s = listaSobrantes[idx]; if (!s || !s.item) return; agotadoSet.delete(s.item.id); soldSet.delete(s.item.id); await saveAgotadoSet(); await saveSoldSet(); registrarAuditoria('Etiqueta sobrante reactivada', s.item.id + ' vuelve a disponible'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); btn.closest('.sr2-acciones').querySelectorAll('button').forEach(b => b.classList.add('hecho')); alertBonito(s.item.id + ' ya quedó disponible de nuevo.'); })); cont.querySelectorAll('.sr2-reimprimir').forEach(btn => btn.addEventListener('click', async (e) => { const idx = parseInt(btn.dataset.idx, 10); const s = listaSobrantes[idx]; if (!s || !s.item) return; const it = s.item; await generarEtiquetasPDF([{ id: it.id, nombre: (it.marca + ' ' + it.modelo).trim(), specs: construirSpecsEtiqueta(it), cantidad: 1, }]); })); } document.getElementById('etiquetasSobrantesBtn').addEventListener('click', () => { listaSobrantes = []; renderSobrantes(); document.getElementById('etiquetasSobrantesModal').style.display = 'flex'; document.getElementById('sobranteIdInput').value = ''; document.getElementById('sobranteIdInput').focus(); }); document.getElementById('sobrantesCerrarBtn').addEventListener('click', () => { document.getElementById('etiquetasSobrantesModal').style.display = 'none'; }); document.getElementById('sobranteIdInput').addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); const input = document.getElementById('sobranteIdInput'); const idRaw = input.value.trim().toUpperCase(); if (!idRaw) return; const id = INDEX[idRaw] ? idRaw : normalizarCodigoEscaneado(idRaw); if (listaSobrantes.some(s => s.id === id)) { input.value = ''; return; } listaSobrantes.unshift({ id, item: INDEX[id] || null }); renderSobrantes(); input.value = ''; }); // Guarda un escaneo en la nube, mezclandolo con lo que otros dispositivos ya hayan escaneado, // para que el conteo se comparta en tiempo real entre todos los que esten contando a la vez. async function agregarEscaneoConteoEnLinea(id) { try { const remoto = await getConteoRemoto(); const set = new Set(remoto && Array.isArray(remoto.escaneados) ? remoto.escaneados : []); set.add(id); remoto && remoto.escaneados && remoto.escaneados.forEach(x => conteoEscaneados.add(x)); conteoEscaneados.add(id); actualizarBannerConteo(); await setConteoRemoto({ activo: true, iniciadoPor: (remoto && remoto.iniciadoPor) || (currentSession ? currentSession.nombre : 'N/D'), fechaInicio: (remoto && remoto.fechaInicio) || new Date().toISOString(), escaneados: [...set], }); } catch (e) { /* si falla, el escaneo ya quedo marcado localmente y se reintentara despues */ } } let conteoActivo = false; let conteoEscaneados = new Set(); let conteoCategoria = ''; // vacio = todo el inventario function itemsParaConteo() { return DATA.filter(d => !soldSet.has(d.id) && (!conteoCategoria || (d.categoria || 'Laptops') === conteoCategoria)); } function totalDisponiblesParaConteo() { return itemsParaConteo().length; } function actualizarBannerConteo() { const banner = document.getElementById('conteoBanner'); if (!conteoActivo) { banner.style.display = 'none'; return; } banner.style.display = 'flex'; document.getElementById('conteoBannerTexto').textContent = 'Conteo en progreso (' + (conteoCategoria || 'todo el inventario') + '): ' + conteoEscaneados.size + ' de ' + totalDisponiblesParaConteo() + ' escaneados'; } async function continuarConteoRemoto(remoto) { conteoActivo = true; conteoCategoria = remoto.categoria || ''; conteoEscaneados = new Set(Array.isArray(remoto.escaneados) ? remoto.escaneados : []); actualizarBannerConteo(); showScanView(); } async function iniciarNuevoConteo(categoria) { conteoActivo = true; conteoCategoria = categoria || ''; conteoEscaneados = new Set(); try { await setConteoRemoto({ activo: true, categoria: conteoCategoria, iniciadoPor: currentSession ? currentSession.nombre : 'N/D', fechaInicio: new Date().toISOString(), escaneados: [], }); } catch (e) {} actualizarBannerConteo(); showScanView(); } document.getElementById('conteoBtn').addEventListener('click', async () => { let remoto = null; try { remoto = await getConteoRemoto(); } catch (e) {} if (remoto && remoto.activo) { const cuantos = Array.isArray(remoto.escaneados) ? remoto.escaneados.length : 0; const catTexto = remoto.categoria ? ('de la categoría "' + remoto.categoria + '"') : 'de todo el inventario'; if (!(await confirmarBonito(cuantos + ' producto(s) escaneados hasta ahora. Lo que escanees se sumará al conteo compartido en línea.', '¿Continuar el conteo ' + catTexto + ' (iniciado por ' + (remoto.iniciadoPor || 'otro usuario') + ')?'))) return; await continuarConteoRemoto(remoto); } else { document.getElementById('conteoCategoriaSelect').value = ''; document.getElementById('conteoCategoriaModal').style.display = 'flex'; } }); document.getElementById('conteoCategoriaCancelarBtn').addEventListener('click', () => { document.getElementById('conteoCategoriaModal').style.display = 'none'; }); document.getElementById('conteoCategoriaIniciarBtn').addEventListener('click', async () => { const categoria = document.getElementById('conteoCategoriaSelect').value; const texto = categoria ? 'Solo se compararán los productos de esa categoría — lo demás del inventario no se verá afectado.' : 'A partir de ahora, cada producto que escanees se irá marcando como "encontrado". Al final podrás ver cuáles faltaron y decidir si se eliminan o se marcan como agotadas.'; const titulo = categoria ? ('¿Iniciar un conteo físico solo de "' + categoria + '"?') : '¿Iniciar un conteo físico de TODO el inventario?'; if (!(await confirmarBonito(texto, titulo))) return; document.getElementById('conteoCategoriaModal').style.display = 'none'; await iniciarNuevoConteo(categoria); }); document.getElementById('conteoFinalizarBtn').addEventListener('click', async () => { // se trae lo ultimo escaneado por otros dispositivos antes de cerrar, por si alguien // mas siguio escaneando mientras tanto try { const remoto = await getConteoRemoto(); if (remoto && Array.isArray(remoto.escaneados)) { remoto.escaneados.forEach(id => conteoEscaneados.add(id)); } } catch (e) {} conteoActivo = false; actualizarBannerConteo(); try { await setConteoRemoto({ activo: false, categoria: conteoCategoria, escaneados: [...conteoEscaneados], fechaFin: new Date().toISOString() }); } catch (e) {} const faltantes = itemsParaConteo().filter(d => !conteoEscaneados.has(d.id)); document.getElementById('conteoResultadoResumen').textContent = 'Categoría: ' + (conteoCategoria || 'todo el inventario') + ' · ' + conteoEscaneados.size + ' escaneados de ' + totalDisponiblesParaConteo() + ' disponibles · ' + faltantes.length + ' no aparecieron'; const lista = document.getElementById('conteoResultadoLista'); if (faltantes.length === 0) { lista.innerHTML = '

¡Todo el inventario disponible fue escaneado!

'; } else { lista.innerHTML = faltantes.map(d => `
${d.marca} ${d.modelo} ${d.id}
${money(d.precioBueno)}
`).join(''); } document.getElementById('conteoMarcarAgotadoBtn').style.display = faltantes.length > 0 ? 'block' : 'none'; const esAdmin = currentSession && currentSession.rol === 'admin'; document.getElementById('conteoEliminarBtn').style.display = (faltantes.length > 0 && esAdmin) ? 'block' : 'none'; document.getElementById('conteoResultadoModal').dataset.faltantes = JSON.stringify(faltantes.map(d => d.id)); document.getElementById('conteoResultadoModal').style.display = 'flex'; }); document.getElementById('conteoResultadoCerrarBtn').addEventListener('click', () => { document.getElementById('conteoResultadoModal').style.display = 'none'; }); document.getElementById('conteoMarcarAgotadoBtn').addEventListener('click', async () => { const ids = JSON.parse(document.getElementById('conteoResultadoModal').dataset.faltantes || '[]'); if (ids.length === 0) return; if (!(await confirmarBonito('Dejarán de contar como disponibles, pero no se borrarán del historial.', '¿Marcar ' + ids.length + ' producto(s) como agotados?'))) return; ids.forEach(id => { soldSet.add(id); agotadoSet.add(id); }); await saveSoldSet(); await saveAgotadoSet(); registrarAuditoria('Conteo de inventario', ids.length + ' producto(s) marcados como agotados'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); document.getElementById('conteoResultadoModal').style.display = 'none'; alertBonito('Listo, ' + ids.length + ' producto(s) marcados como agotados.'); }); document.getElementById('conteoEliminarBtn').addEventListener('click', async () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede eliminar productos del inventario. Puedes usar "Marcar como agotadas" mientras tanto.'); return; } const ids = JSON.parse(document.getElementById('conteoResultadoModal').dataset.faltantes || '[]'); if (ids.length === 0) return; if (!(await confirmarBonito('Quedarán guardados en el historial de eliminados (descarga de inventario), pero ya no aparecerán como disponibles.', '¿Eliminar ' + ids.length + ' producto(s) del inventario?'))) return; for (const id of ids) { const item = INDEX[id]; if (!item) continue; const idx = DATA.findIndex(d => d.id === id); if (idx !== -1) DATA.splice(idx, 1); delete INDEX[id]; try { const eliminados = await getEliminados(); eliminados.push(Object.assign({}, item, { fechaEliminado: new Date().toISOString(), eliminadoPor: currentSession ? currentSession.nombre : 'N/D' })); await setListToStorage(ELIMINADOS_KEY, eliminados); } catch (e) {} try { let extras = await getListFromStorage(EXTRA_INV_KEY); const nuevasExtras = extras.filter(e => e.id !== id); if (nuevasExtras.length !== extras.length) await setListToStorage(EXTRA_INV_KEY, nuevasExtras); } catch (e) {} } registrarAuditoria('Conteo de inventario', ids.length + ' producto(s) eliminados por no aparecer en el conteo'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); document.getElementById('conteoResultadoModal').style.display = 'none'; alertBonito('Listo, ' + ids.length + ' producto(s) eliminados.'); }); // --- Reparar datos: revisa todas las listas guardadas por registros dañados y los limpia --- const CLAVES_REPARABLES = [ { clave: 'ventas_inventario', nombre: 'Ventas de inventario' }, { clave: 'ventas_externas', nombre: 'Ventas externas' }, { clave: 'inventario_extra', nombre: 'Mercancía agregada' }, { clave: 'inventario_eliminados', nombre: 'Historial de eliminados' }, { clave: 'inventario_correcciones', nombre: 'Correcciones de productos' }, { clave: 'caja_movimientos', nombre: 'Movimientos de caja' }, { clave: 'gastos', nombre: 'Gastos' }, { clave: 'auditoria', nombre: 'Auditoría' }, ]; async function escanearDatosDaniados() { const resultados = []; for (const { clave, nombre } of CLAVES_REPARABLES) { let lista = []; try { lista = await getListFromStorage(clave); } catch (e) { lista = []; } const originales = lista.length; const limpios = lista.filter(x => x && typeof x === 'object').length; const daniados = originales - limpios; if (daniados > 0) resultados.push({ clave, nombre, daniados, originales }); } return resultados; } document.getElementById('repararDatosBtn').addEventListener('click', async () => { const resumenEl = document.getElementById('repararResumen'); const listaEl = document.getElementById('repararLista'); resumenEl.textContent = 'Revisando...'; listaEl.innerHTML = ''; document.getElementById('repararAhoraBtn').style.display = 'none'; document.getElementById('repararDatosModal').style.display = 'flex'; const resultados = await escanearDatosDaniados(); if (resultados.length === 0) { resumenEl.textContent = 'Todo está en orden, no se encontró nada dañado.'; return; } const totalDaniados = resultados.reduce((s, r) => s + r.daniados, 0); resumenEl.textContent = 'Se encontraron ' + totalDaniados + ' registro(s) dañado(s):'; listaEl.innerHTML = resultados.map(r => `
${r.nombre}
${r.daniados} de ${r.originales}
`).join(''); document.getElementById('repararAhoraBtn').style.display = 'block'; document.getElementById('repararDatosModal').dataset.claves = JSON.stringify(resultados.map(r => r.clave)); }); document.getElementById('repararAhoraBtn').addEventListener('click', async () => { const claves = JSON.parse(document.getElementById('repararDatosModal').dataset.claves || '[]'); if (claves.length === 0) return; if (!(await confirmarBonito('Esto no afecta ninguno de tus datos buenos.', '¿Eliminar los registros dañados encontrados?'))) return; for (const clave of claves) { let lista = []; try { lista = await getListFromStorage(clave); } catch (e) { lista = []; } const limpios = lista.filter(x => x && typeof x === 'object'); try { await setListToStorage(clave, limpios); } catch (e) {} } registrarAuditoria('Reparó datos', claves.join(', ')); document.getElementById('repararDatosModal').style.display = 'none'; alertBonito('Listo, se repararon los datos.'); }); document.getElementById('repararCerrarBtn').addEventListener('click', () => { document.getElementById('repararDatosModal').style.display = 'none'; }); // --- Reconciliar conteo desde archivo: lee el .xls que genera "Bajar inventario" (con los // resultados de un conteo físico) y lo compara contra lo que hay AHORITA en la nube, mostrando // solo las diferencias antes de aplicar nada. Sirve para cuando un conteo se hizo en un // dispositivo y por algún motivo esos cambios no llegaron a sincronizarse. --- function parsearInventarioXls(texto) { const doc = new DOMParser().parseFromString(texto, 'text/html'); const filas = Array.from(doc.querySelectorAll('table tr')).slice(1); // se salta el encabezado const items = []; filas.forEach(tr => { const celdas = Array.from(tr.querySelectorAll('td')).map(td => td.textContent.trim()); if (!celdas.length || !celdas[0]) return; const [id, estado, anaquel, nivel, marca, modelo] = celdas; items.push({ id, estado: (estado || '').toUpperCase(), anaquel, nivel, marca, modelo }); }); return items; } async function calcularDiferenciasConteo(itemsArchivo, onProgress) { const reportar = (pct, texto) => { if (onProgress) onProgress(pct, texto); }; reportar(5, 'Descargando estado actual de la nube...'); const vendidosActuales = new Set(await getListFromStorage('vendidos')); reportar(20, 'Descargando estado actual de la nube...'); const agotadosActuales = new Set(await getListFromStorage('agotados')); reportar(35, 'Descargando historial de ventas...'); const ventas = await getListFromStorage('ventas_inventario'); reportar(50, 'Descargando historial de eliminados...'); const eliminadosIds = new Set((await getEliminados()).map(e => (e && e.id) ? e.id.toUpperCase() : null).filter(Boolean)); const idsConVentaReal = new Set(ventas.filter(v => v && v.id && v.estado !== 'cancelada').map(v => v.id.toUpperCase())); const agregar = [], quitar = [], conflictos = []; let igual = 0; const total = itemsArchivo.length; const LOTE = 20; for (let i = 0; i < total; i++) { const item = itemsArchivo[i]; if (item.id) { const id = item.id.toUpperCase(); if (item.estado !== 'ELIMINADO' && !eliminadosIds.has(id)) { // ese equipo ya no existe en el catalogo, no aplica const marcadoVendidoEnArchivo = item.estado === 'VENDIDO'; const estaVendidoAhora = vendidosActuales.has(id); if (marcadoVendidoEnArchivo) { if (!estaVendidoAhora) agregar.push(item); else igual++; } else { if (estaVendidoAhora) { if (idsConVentaReal.has(id)) conflictos.push(item); else quitar.push(item); } else igual++; } } } if (i % LOTE === 0 || i === total - 1) { const pct = 55 + Math.round(((i + 1) / total) * 43); // 55% -> 98% reportar(pct, 'Comparando equipo ' + (i + 1) + ' de ' + total + '...'); await new Promise(r => setTimeout(r, 0)); // deja que la pantalla se repinte antes de seguir } } reportar(100, 'Comparación lista'); return { agregar, quitar, conflictos, igual }; } async function aplicarDiferenciasConteo(diff, onProgress) { const reportar = (pct, texto) => { if (onProgress) onProgress(pct, texto); }; // se relee justo antes de guardar, por si algo cambió mientras se revisaba el resumen reportar(10, 'Descargando estado actual de la nube...'); const vendidosFrescos = new Set(await getListFromStorage('vendidos')); reportar(25, 'Descargando estado actual de la nube...'); const agotadosFrescos = new Set(await getListFromStorage('agotados')); const cambios = diff.agregar.map(item => ({ item, agotado: true })).concat(diff.quitar.map(item => ({ item, agotado: false }))); const totalCambios = cambios.length; const LOTE = 20; for (let i = 0; i < totalCambios; i++) { const { item, agotado } = cambios[i]; const id = item.id.toUpperCase(); if (agotado) { vendidosFrescos.add(id); agotadosFrescos.add(id); } else { vendidosFrescos.delete(id); agotadosFrescos.delete(id); } if (totalCambios > 0 && (i % LOTE === 0 || i === totalCambios - 1)) { const pct = 30 + Math.round(((i + 1) / totalCambios) * 30); // 30% -> 60% reportar(pct, 'Aplicando cambio ' + (i + 1) + ' de ' + totalCambios + '...'); await new Promise(r => setTimeout(r, 0)); } } reportar(70, 'Guardando en la nube...'); await setListToStorage('vendidos', [...vendidosFrescos]); reportar(85, 'Guardando en la nube...'); await setListToStorage('agotados', [...agotadosFrescos]); soldSet = vendidosFrescos; agotadoSet = agotadosFrescos; reportar(92, 'Registrando en la auditoría...'); await registrarAuditoria('Reconcilió conteo desde archivo', diff.agregar.length + ' marcado(s) agotados, ' + diff.quitar.length + ' regresado(s) a disponibles, ' + diff.conflictos.length + ' conflicto(s) sin tocar'); reportar(98, 'Actualizando pantalla...'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); reportar(100, 'Listo'); } let reconciliarDiffActual = null; function filaReconciliarHtml(item, estilo, texto) { return '
' + (item.marca || '') + ' ' + (item.modelo || '') + '' + item.id + '
' + '' + texto + '
'; } function actualizarBarraProceso(texto, pct, estado) { // estado: 'activo' (mientras trabaja) | 'ok' (terminó bien) | 'error' (falló) | null (ocultar) const wrap = document.getElementById('reconciliarBarraWrap'); const fill = document.getElementById('reconciliarBarraFill'); const textoEl = document.getElementById('reconciliarBarraTexto'); const icono = document.getElementById('reconciliarBarraIcono'); if (!estado) { wrap.className = 'barra-proceso-wrap'; return; } wrap.className = 'barra-proceso-wrap activo ' + (estado === 'activo' ? '' : estado); fill.style.width = pct + '%'; textoEl.textContent = texto; if (estado === 'activo') { icono.className = 'ti ti-loader-2 icono-girando'; } else if (estado === 'ok') { icono.className = 'ti ti-circle-check'; } else { icono.className = 'ti ti-alert-circle'; } } function pintarResumenReconciliacion(diff) { const resumenEl = document.getElementById('reconciliarResumen'); const listaEl = document.getElementById('reconciliarLista'); const total = diff.agregar.length + diff.quitar.length; if (total === 0 && diff.conflictos.length === 0) { resumenEl.textContent = 'Todo coincide con la nube, no hay nada que cambiar.'; listaEl.innerHTML = ''; document.getElementById('reconciliarAplicarBtn').style.display = 'none'; return; } resumenEl.textContent = diff.agregar.length + ' se marcarán como agotados · ' + diff.quitar.length + ' regresarán a disponibles' + (diff.conflictos.length ? ' · ' + diff.conflictos.length + ' conflicto(s) sin tocar' : ''); let html = ''; if (diff.conflictos.length) { html += '

Conflictos — tienen una venta real registrada, no se tocan solos:

'; html += diff.conflictos.map(i => filaReconciliarHtml(i, 'background:rgba(240,149,149,0.18);color:#f09595;', 'conflicto')).join(''); } if (diff.agregar.length) { html += '

Se marcarán como agotados:

'; html += diff.agregar.map(i => filaReconciliarHtml(i, 'background:rgba(255,180,84,0.18);color:#ffb454;', 'agotado')).join(''); } if (diff.quitar.length) { html += '

Regresarán a disponibles:

'; html += diff.quitar.map(i => filaReconciliarHtml(i, 'background:rgba(53,195,240,0.15);color:#35c3f0;', 'disponible')).join(''); } listaEl.innerHTML = html; document.getElementById('reconciliarAplicarBtn').style.display = total > 0 ? 'block' : 'none'; } document.getElementById('reconciliarConteoBtn').addEventListener('click', () => { reconciliarDiffActual = null; document.getElementById('reconciliarResumen').textContent = 'Selecciona el archivo .xls que se bajó con "Bajar inventario" después de contar. Se compara contra lo que hay ahorita en la nube, sin cambiar nada hasta que confirmes.'; document.getElementById('reconciliarLista').innerHTML = ''; document.getElementById('reconciliarAplicarBtn').style.display = 'none'; actualizarBarraProceso('', 0, null); document.getElementById('reconciliarConteoModal').style.display = 'flex'; }); document.getElementById('reconciliarCerrarBtn').addEventListener('click', () => { document.getElementById('reconciliarConteoModal').style.display = 'none'; }); document.getElementById('reconciliarSeleccionarBtn').addEventListener('click', () => { document.getElementById('reconciliarFileInput').click(); }); document.getElementById('reconciliarFileInput').addEventListener('change', async (ev) => { const file = ev.target.files && ev.target.files[0]; ev.target.value = ''; if (!file) return; const resumenEl = document.getElementById('reconciliarResumen'); document.getElementById('reconciliarLista').innerHTML = ''; document.getElementById('reconciliarAplicarBtn').style.display = 'none'; resumenEl.textContent = ''; actualizarBarraProceso('Leyendo archivo...', 20, 'activo'); try { const texto = await file.text(); const items = parsearInventarioXls(texto); if (!items.length) { actualizarBarraProceso('No se pudo leer el archivo', 100, 'error'); resumenEl.textContent = 'No se pudo leer ningún equipo de ese archivo. ¿Es el archivo correcto (el que baja "Bajar inventario")?'; return; } actualizarBarraProceso('Preparando comparación...', 50, 'activo'); const diff = await calcularDiferenciasConteo(items, (pct, texto2) => actualizarBarraProceso(texto2, pct, 'activo')); reconciliarDiffActual = diff; actualizarBarraProceso('Comparación lista', 100, 'ok'); setTimeout(() => actualizarBarraProceso('', 0, null), 1200); pintarResumenReconciliacion(diff); } catch (e) { actualizarBarraProceso('No se pudo comparar', 100, 'error'); resumenEl.textContent = 'No se pudo leer o comparar el archivo: ' + e.message; } }); document.getElementById('reconciliarAplicarBtn').addEventListener('click', async () => { if (!reconciliarDiffActual) return; const diff = reconciliarDiffActual; const total = diff.agregar.length + diff.quitar.length; if (!(await confirmarBonito(diff.agregar.length + ' equipo(s) se marcarán como agotados.\n' + diff.quitar.length + ' equipo(s) volverán a estar disponibles.', '¿Aplicar estos cambios a la nube?'))) return; document.getElementById('reconciliarAplicarBtn').disabled = true; actualizarBarraProceso('Iniciando...', 5, 'activo'); try { await aplicarDiferenciasConteo(diff, (pct, texto2) => actualizarBarraProceso(texto2, pct, 'activo')); actualizarBarraProceso('Listo', 100, 'ok'); setTimeout(() => { actualizarBarraProceso('', 0, null); document.getElementById('reconciliarConteoModal').style.display = 'none'; }, 900); alertBonito('Listo, se aplicaron ' + total + ' cambio(s). Ya se va a ver igual en cualquier dispositivo.'); } catch (e) { actualizarBarraProceso('No se pudo aplicar', 100, 'error'); alertBonito('No se pudo aplicar: ' + e.message + '. Intenta de nuevo.'); } finally { document.getElementById('reconciliarAplicarBtn').disabled = false; } }); async function eliminarProducto(item) { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede eliminar productos del inventario.'); return; } const idU = item.id.toUpperCase(); const nombre = (item.marca + ' ' + item.modelo).trim(); const estaVendido = soldSet.has(item.id); const advertenciaVendido = estaVendido ? '\n\n⚠️ Este producto ya esta marcado como VENDIDO.' : ''; const confirmar = await confirmarBonito( idU + ' — ' + nombre + advertenciaVendido + '\n\nSe guardará en el historial de eliminados (visible al descargar el inventario), y puedes usar "Deshacer" justo después si te equivocaste.', '¿Eliminar este producto del inventario?' ); if (!confirmar) return; // saca el producto de DATA/INDEX en memoria const idx = DATA.findIndex(d => (d.id || '').toUpperCase() === idU); if (idx !== -1) DATA.splice(idx, 1); delete INDEX[idU]; // si venia de mercancia agregada manualmente, tambien se quita de esa lista try { let extras = await getListFromStorage(EXTRA_INV_KEY); const nuevasExtras = extras.filter(e => (e.id || '').toUpperCase() !== idU); if (nuevasExtras.length !== extras.length) { await setListToStorage(EXTRA_INV_KEY, nuevasExtras); } } catch (e) {} // se guarda en el historial de eliminados para el reporte de descarga try { const eliminados = await getEliminados(); eliminados.push(Object.assign({}, item, { fechaEliminado: new Date().toISOString(), eliminadoPor: currentSession ? currentSession.nombre : 'N/D', })); await setListToStorage(ELIMINADOS_KEY, eliminados); } catch (e) {} registrarUltimaAccion({ tipo: 'eliminacion', item: Object.assign({}, item), descripcion: 'eliminación de ' + idU + ' — ' + nombre }); registrarAuditoria('Eliminó producto', idU + ' — ' + nombre); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); alertBonito('Producto ' + idU + ' eliminado del inventario.'); } // --- Eliminar en bloque todos los productos marcados como agotados --- async function eliminarTodosLosAgotados() { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede eliminar productos del inventario.'); return; } const agotadosActuales = DATA.filter(d => agotadoSet.has(d.id)); if (agotadosActuales.length === 0) { alertBonito('No hay ningún producto marcado como agotado ahorita.'); return; } const confirmar = await confirmarBonito( 'Se guardarán en el historial de eliminados (visible al descargar el inventario), y puedes usar "Deshacer" justo después si te equivocaste.', '¿Eliminar los ' + agotadosActuales.length + ' producto(s) marcados como agotados?' ); if (!confirmar) return; const idsSet = new Set(agotadosActuales.map(d => d.id.toUpperCase())); const copias = agotadosActuales.map(it => Object.assign({}, it, { _soldAlEliminar: soldSet.has(it.id), _agotadoAlEliminar: agotadoSet.has(it.id) })); // saca los productos de DATA/INDEX en memoria agotadosActuales.forEach(item => { const idU = item.id.toUpperCase(); const idx = DATA.findIndex(d => (d.id || '').toUpperCase() === idU); if (idx !== -1) DATA.splice(idx, 1); delete INDEX[idU]; }); // si alguno venia de mercancia agregada manualmente, tambien se quita de esa lista try { let extras = await getListFromStorage(EXTRA_INV_KEY); const nuevasExtras = extras.filter(e => !idsSet.has((e.id || '').toUpperCase())); if (nuevasExtras.length !== extras.length) await setListToStorage(EXTRA_INV_KEY, nuevasExtras); } catch (e) {} // se guardan en el historial de eliminados para el reporte de descarga try { const eliminados = await getEliminados(); const ahora = new Date().toISOString(); copias.forEach(item => { eliminados.push(Object.assign({}, item, { fechaEliminado: ahora, eliminadoPor: currentSession ? currentSession.nombre : 'N/D' })); }); await setListToStorage(ELIMINADOS_KEY, eliminados); } catch (e) {} // ya no existen, se quitan de vendidos/agotados agotadosActuales.forEach(item => { soldSet.delete(item.id); agotadoSet.delete(item.id); }); await saveSoldSet(); await saveAgotadoSet(); registrarUltimaAccion({ tipo: 'eliminacion_masiva', items: copias, descripcion: 'eliminación de ' + copias.length + ' producto(s) agotados' }); registrarAuditoria('Eliminó productos agotados', copias.length + ' producto(s)'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); alertBonito('Listo, se eliminaron ' + copias.length + ' producto(s) agotados.'); } document.getElementById('eliminarAgotadosBtn').addEventListener('click', eliminarTodosLosAgotados); // --- Eliminar TODO el inventario disponible de una categoría completa --- // (CATEGORIAS_INVENTARIO ya está definida más abajo, junto al filtro de categorías de la lista) function itemsDeCategoria(categoria) { return DATA.filter(d => (d.categoria || 'Laptops') === categoria); } function actualizarConteoEliminarCategoria() { const categoria = document.getElementById('eliminarCategoriaSelect').value; const items = itemsDeCategoria(categoria); const vendidos = items.filter(it => soldSet.has(it.id) && !agotadoSet.has(it.id)).length; const agotados = items.filter(it => agotadoSet.has(it.id)).length; const disponibles = items.length - vendidos - agotados; const conteoEl = document.getElementById('eliminarCategoriaConteo'); if (items.length === 0) { conteoEl.textContent = 'No hay ningún producto en "' + categoria + '" ahorita.'; } else { conteoEl.textContent = items.length + ' producto(s) en total en "' + categoria + '" — ' + disponibles + ' disponible(s), ' + agotados + ' agotado(s), ' + vendidos + ' vendido(s).'; } } document.getElementById('eliminarCategoriaBtn').addEventListener('click', () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede eliminar productos del inventario.'); return; } const select = document.getElementById('eliminarCategoriaSelect'); select.innerHTML = CATEGORIAS_INVENTARIO.map(c => '').join(''); actualizarConteoEliminarCategoria(); document.getElementById('eliminarCategoriaModal').style.display = 'flex'; }); document.getElementById('eliminarCategoriaSelect').addEventListener('change', actualizarConteoEliminarCategoria); document.getElementById('eliminarCategoriaCancelarBtn').addEventListener('click', () => { document.getElementById('eliminarCategoriaModal').style.display = 'none'; }); document.getElementById('eliminarCategoriaEliminarBtn').addEventListener('click', async () => { const categoria = document.getElementById('eliminarCategoriaSelect').value; const items = itemsDeCategoria(categoria); if (items.length === 0) { alertBonito('No hay ningún producto en "' + categoria + '" ahorita.'); return; } const vendidos = items.filter(it => soldSet.has(it.id) && !agotadoSet.has(it.id)).length; const agotados = items.filter(it => agotadoSet.has(it.id)).length; let advertencia = ''; if (agotados > 0 || vendidos > 0) { advertencia = '\n\nDe estos, ' + agotados + ' están agotados y ' + vendidos + ' ya están vendidos — se incluyen y se eliminan también.'; } const confirmar = await confirmarBonito( 'Se guardarán en el historial de eliminados (visible al descargar el inventario), y puedes usar "Deshacer" justo después si te equivocaste.' + advertencia, '¿Eliminar los ' + items.length + ' producto(s) de "' + categoria + '"?' ); if (!confirmar) return; document.getElementById('eliminarCategoriaModal').style.display = 'none'; const idsSet = new Set(items.map(d => d.id.toUpperCase())); const copias = items.map(it => Object.assign({}, it, { _soldAlEliminar: soldSet.has(it.id), _agotadoAlEliminar: agotadoSet.has(it.id) })); // saca los productos de DATA/INDEX en memoria items.forEach(item => { const idU = item.id.toUpperCase(); const idx = DATA.findIndex(d => (d.id || '').toUpperCase() === idU); if (idx !== -1) DATA.splice(idx, 1); delete INDEX[idU]; }); // si alguno venia de mercancia agregada manualmente, tambien se quita de esa lista try { let extras = await getListFromStorage(EXTRA_INV_KEY); const nuevasExtras = extras.filter(e => !idsSet.has((e.id || '').toUpperCase())); if (nuevasExtras.length !== extras.length) await setListToStorage(EXTRA_INV_KEY, nuevasExtras); } catch (e) {} // se guardan en el historial de eliminados para el reporte de descarga try { const eliminados = await getEliminados(); const ahora = new Date().toISOString(); copias.forEach(item => { eliminados.push(Object.assign({}, item, { fechaEliminado: ahora, eliminadoPor: currentSession ? currentSession.nombre : 'N/D' })); }); await setListToStorage(ELIMINADOS_KEY, eliminados); } catch (e) {} // ya no existen, se quitan de vendidos/agotados items.forEach(item => { soldSet.delete(item.id); agotadoSet.delete(item.id); }); await saveSoldSet(); await saveAgotadoSet(); registrarUltimaAccion({ tipo: 'eliminacion_masiva', items: copias, descripcion: 'eliminación de la categoría "' + categoria + '" (' + copias.length + ' producto(s))' }); registrarAuditoria('Eliminó categoría completa', categoria + ' — ' + copias.length + ' producto(s)'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); alertBonito('Listo, se eliminaron ' + copias.length + ' producto(s) de "' + categoria + '".'); }); // --- Quitar mercancía que se importó por error al inventario desde un proveedor (Excel // o carga manual) — solo muestra la que todavía tiene precio y ubicación pendientes, // que es casi seguro la que nadie ha tocado todavía. --- function itemsImportadosPendientesDeProveedor() { return DATA.filter(d => (d.registradoDesde === 'proveedor-excel' || d.registradoDesde === 'proveedor-manual') && (!d.precioBueno || d.precioBueno === 0) && !d.anaquel ); } document.getElementById('quitarImportadoProveedorBtn').addEventListener('click', () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede eliminar productos del inventario.'); return; } const items = itemsImportadosPendientesDeProveedor(); document.getElementById('quitarImportadoConteo').textContent = items.length + ' producto(s) encontrados'; const lista = document.getElementById('quitarImportadoLista'); if (items.length === 0) { lista.innerHTML = '

No hay mercancía importada de proveedor pendiente por revisar.

'; } else { lista.innerHTML = items.map(it => `
${escapeHtmlPreview(it.marca)} (${it.id} · ${it.categoria}${it.fechaAgregado ? ' · ' + new Date(it.fechaAgregado).toLocaleDateString('es-MX') : ''})
`).join(''); } document.getElementById('quitarImportadoProveedorModal').style.display = 'flex'; }); document.getElementById('quitarImportadoCerrarBtn').addEventListener('click', () => { document.getElementById('quitarImportadoProveedorModal').style.display = 'none'; }); document.getElementById('quitarImportadoEliminarBtn').addEventListener('click', async () => { const marcados = Array.from(document.querySelectorAll('.quitar-importado-check:checked')).map(c => c.dataset.id); if (marcados.length === 0) { alertBonito('No marcaste ningún producto.'); return; } if (!(await confirmarBonito('Se guardan en el historial de eliminados y puedes usar "Deshacer" justo después si te equivocaste.', '¿Eliminar ' + marcados.length + ' producto(s)?'))) return; const idsSet = new Set(marcados.map(id => id.toUpperCase())); const items = DATA.filter(d => idsSet.has((d.id || '').toUpperCase())); const copias = items.map(it => Object.assign({}, it, { _soldAlEliminar: soldSet.has(it.id), _agotadoAlEliminar: agotadoSet.has(it.id) })); items.forEach(item => { const idU = item.id.toUpperCase(); const idx = DATA.findIndex(d => (d.id || '').toUpperCase() === idU); if (idx !== -1) DATA.splice(idx, 1); delete INDEX[idU]; }); try { let extras = await getListFromStorage(EXTRA_INV_KEY); const nuevasExtras = extras.filter(e => !idsSet.has((e.id || '').toUpperCase())); await setListToStorage(EXTRA_INV_KEY, nuevasExtras); } catch (e) {} try { const eliminados = await getEliminados(); const ahora = new Date().toISOString(); copias.forEach(item => eliminados.push(Object.assign({}, item, { fechaEliminado: ahora, eliminadoPor: currentSession ? currentSession.nombre : 'N/D' }))); await setListToStorage(ELIMINADOS_KEY, eliminados); } catch (e) {} items.forEach(item => { soldSet.delete(item.id); agotadoSet.delete(item.id); }); await saveSoldSet(); await saveAgotadoSet(); registrarUltimaAccion({ tipo: 'eliminacion_masiva', items: copias, descripcion: 'quitar mercancía importada de proveedor (' + copias.length + ' producto(s))' }); registrarAuditoria('Quitó mercancía importada de proveedor', copias.length + ' producto(s)'); document.getElementById('quitarImportadoProveedorModal').style.display = 'none'; updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); alertBonito('Se eliminaron ' + copias.length + ' producto(s).'); }); // --- Ver / ajustar el contador de folios de laptop (A-000001, A-000002...) — el mismo // que usan tanto la página como la USB, para que nunca se repita un folio. --- function folioMasAltoEnUso() { let maximo = 0; DATA.forEach(d => { const m = /^A-(\d{6})$/.exec((d.id || '').toUpperCase()); if (m) maximo = Math.max(maximo, parseInt(m[1], 10)); }); return maximo; } document.getElementById('ajustarContadorBtn').addEventListener('click', async () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede ajustar esto.'); return; } let estado = {}; try { estado = await getObjFromStorage('contador_laptops'); } catch (e) { estado = {}; } const siguienteActual = (estado && estado.siguiente > 0) ? estado.siguiente : 1; const maximoEnUso = folioMasAltoEnUso(); document.getElementById('ajustarContadorActual').textContent = 'Siguiente folio que se va a usar ahorita: A-' + String(siguienteActual).padStart(6, '0'); document.getElementById('ajustarContadorMaximo').textContent = maximoEnUso > 0 ? ('A-' + String(maximoEnUso).padStart(6, '0') + ' (no bajes de aquí, o vuelve a checar antes de guardar)') : 'Todavía no hay ninguna laptop registrada con este formato de folio.'; document.getElementById('ajustarContadorInput').value = String(siguienteActual); document.getElementById('ajustarContadorModal').style.display = 'flex'; }); document.getElementById('ajustarContadorCerrarBtn').addEventListener('click', () => { document.getElementById('ajustarContadorModal').style.display = 'none'; }); document.getElementById('ajustarContadorGuardarBtn').addEventListener('click', async () => { const nuevo = parseInt(document.getElementById('ajustarContadorInput').value.replace(/[^0-9]/g, ''), 10); if (!nuevo || nuevo < 1) { alertBonito('Escribe un número válido, mayor a cero.'); return; } const maximoEnUso = folioMasAltoEnUso(); if (nuevo <= maximoEnUso) { const seguro = await confirmarBonito( 'El folio A-' + String(maximoEnUso).padStart(6, '0') + ' ya está en uso. Si continúas, la próxima laptop que registres puede que se brinque varios números hasta encontrar uno libre — no se va a repetir ninguno, pero tampoco va a quedar perfectamente en orden.', '¿Continuar de todas formas?' ); if (!seguro) return; } try { await setObjToStorage('contador_laptops', { siguiente: nuevo }); document.getElementById('ajustarContadorModal').style.display = 'none'; alertBonito('Listo. La próxima laptop que registres (desde la página o la USB) va a usar el folio A-' + String(nuevo).padStart(6, '0') + '.'); } catch (e) { alertBonito('No se pudo guardar. Revisa tu conexión e intenta de nuevo.'); } }); // --- Imprimir etiquetas normales (sin ninguna marca de "agotado") para los // productos marcados como agotados, por si se van a reetiquetar o reponer --- document.getElementById('imprimirEtiquetasAgotadosBtn').addEventListener('click', async () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede imprimir las etiquetas de los agotados.'); return; } const agotadosActuales = DATA.filter(d => agotadoSet.has(d.id)); if (agotadosActuales.length === 0) { alertBonito('No hay ningún producto marcado como agotado ahorita.'); return; } const confirmar = await confirmarBonito( 'Salen como etiquetas normales, iguales a las de cualquier otro producto — no van a decir "agotado" ni nada parecido.', '¿Imprimir ' + agotadosActuales.length + ' etiqueta(s) de los productos agotados?' ); if (!confirmar) return; const etiquetas = agotadosActuales.slice().sort((a, b) => compararIds(a.id, b.id)).map(it => ({ id: it.id, nombre: (it.marca + ' ' + it.modelo).trim(), specs: construirSpecsEtiqueta(it), cantidad: 1, })); await generarEtiquetasPDF(etiquetas); }); document.getElementById('checadorBtn').addEventListener('click', () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede abrir el checador.'); return; } window.open('checador.html', '_blank'); }); document.getElementById('restaurarAgotadosBtn').addEventListener('click', async () => { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('Solo un administrador puede hacer esto.'); return; } const ids = new Set([...agotadoSet, ...soldSet]); if (ids.size === 0) { alertBonito('No hay ningún producto marcado como agotado o vendido ahorita.'); return; } const confirmar = await confirmarBonito( 'IMPORTANTE: esto también va a afectar ventas reales que hayas registrado — van a dejar de contar como vendidas y van a volver a aparecer como disponibles en tu inventario. Úsalo solo si sabes que la mayoría de esas marcas vienen del conteo que salió mal, no de ventas de verdad.\n\nEl historial de ventas (lo que ya descargaste o el reporte de caja) no se borra, solo cambia si el producto cuenta como disponible o no.', '¿Restaurar los ' + ids.size + ' producto(s) agotados y vendidos a "disponible"?' ); if (!confirmar) return; ids.forEach(id => { agotadoSet.delete(id); soldSet.delete(id); }); await saveAgotadoSet(); await saveSoldSet(); registrarAuditoria('Restaurar agotados y vendidos', ids.size + ' producto(s) restaurados a disponible'); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); alertBonito('Listo, ' + ids.size + ' producto(s) quedaron disponibles de nuevo.'); }); // Formato para laptops/equipos de computo: A{anaquel}-{nivel de 2 digitos}-{pieza de 2 digitos} // Ej. anaquel 1, nivel 2, pieza 15 -> "A1-02-15". Revisa el INDEX para nunca repetir un ID. // Para mercancia que no es laptop, el ID no sigue el formato de anaquel/nivel: solo debe ser unico. function generarIdAleatorioGeneral() { let id; do { id = 'M-' + Math.random().toString(36).slice(2, 8).toUpperCase(); } while (INDEX[id]); return id; } // Las laptops usan un contador secuencial COMPARTIDO (mismo backend que el resto de los // datos), en formato A-000001, A-000002... — nunca se repite y siempre sigue subiendo, // sin importar si la laptop se registró desde la página o desde la USB (ambas usan este // mismo contador). Reserva de una sola vez "cuantos" IDs seguidos (para cuando se agregan // varias laptops idénticas en un solo lote), leyendo y guardando el contador una sola vez. const CONTADOR_LAPTOPS_KEY = 'contador_laptops'; async function reservarIdsLaptopSecuenciales(cuantos) { let estado = {}; try { estado = await getObjFromStorage(CONTADOR_LAPTOPS_KEY); } catch (e) { estado = {}; } let siguiente = (estado && estado.siguiente > 0) ? estado.siguiente : 1; const ids = []; for (let i = 0; i < cuantos; i++) { let candidato; do { candidato = 'A-' + String(siguiente).padStart(6, '0'); siguiente += 1; } while (INDEX[candidato.toUpperCase()]); // por si ya existiera ese folio (ej. de un respaldo viejo), lo brinca ids.push(candidato); } try { await setObjToStorage(CONTADOR_LAPTOPS_KEY, { siguiente }); } catch (e) { /* si falla el guardado, el siguiente intento retoma desde el ultimo valor que si se guardó */ } return ids; } // Convierte abreviaciones comunes de procesador ("i5-7ma", "i7 10ma gen", "ryzen 5 5500u") // al estilo completo que usa el inventario ("Intel Core i5 de 7.ª Generación", "AMD Ryzen 5 5500U"). // Arma el bloque de especificaciones para la etiqueta: procesador en su propia linea, // luego "RAM Xgb | Almacenamiento" en otra linea, y touch/gpu si aplican. Cada linea // va separada por salto de linea para que generarEtiquetasPDF la imprima como renglones aparte. function construirSpecsEtiqueta(item) { const lineas = []; if (item.procesador) lineas.push(item.procesador); if (item.ram) lineas.push('RAM ' + item.ram); if (item.almacenamiento) lineas.push(item.almacenamiento); if (item.touch) lineas.push(item.touch); if (item.gpu) lineas.push('GPU: ' + item.gpu); return lineas.join('\n'); } function normalizarProcesador(texto) { if (!texto) return texto; let t = texto.trim(); // Todas las terminaciones de ordinal en español que la gente suele escribir: // 1er, 2do, 3ro/3ra, 4to/4ta, 5to/5ta, 6to/6ta, 7mo/7ma, 8vo/8va, 9no/9na, 10mo/10ma, 11vo, 12vo... const sufijos = 'va|na|ma|er|do|to|ra|ta|ro|vo|mo|no'; // Intel Core iX con generacion abreviada, serie primero: "i5-7ma", "i5 7ma gen", // "i7 10ma generacion", "i3 4ta", "i5 6ta", "i56ta" (sin espacio) let m = new RegExp('\\bi([3579])\\s*[-\\s]?\\s*(\\d{1,2})\\s*(?:' + sufijos + ')?\\.?\\s*(?:gen(?:eraci[oó]n)?)?\\b', 'i').exec(t); if (m) return 'Intel Core i' + m[1] + ' de ' + m[2] + '.ª Generación'; // Mismo caso pero al reves, generacion primero: "6ta gen i5", "6ta i5", "gen 6 i5" m = new RegExp('\\b(\\d{1,2})\\s*(?:' + sufijos + ')?\\.?\\s*(?:gen(?:eraci[oó]n)?)?\\s*[-\\s]?\\s*i([3579])\\b', 'i').exec(t); if (m) return 'Intel Core i' + m[2] + ' de ' + m[1] + '.ª Generación'; // Ya trae "core iX" pero le falta "Intel" o el formato de generacion m = /\bcore\s*i([3579])\b/i.exec(t); if (m) { const serie = m[1]; const genMatch = new RegExp('(\\d{1,2})\\s*(?:' + sufijos + ')?\\.?\\s*gen', 'i').exec(t); const prefix = /intel/i.test(t) ? '' : 'Intel '; if (genMatch) return prefix + 'Core i' + serie + ' de ' + genMatch[1] + '.ª Generación'; return (prefix + t).replace(/\s+/g, ' ').trim(); } // Solo "i5", "i7", etc, sin nada mas escrito m = /^i([3579])$/i.exec(t); if (m) return 'Intel Core i' + m[1]; // AMD Ryzen sin el prefijo "AMD" (y con mayúscula inicial en "Ryzen") if (/ryzen/i.test(t)) { t = t.replace(/ryzen/i, 'Ryzen'); if (!/amd/i.test(t)) t = 'AMD ' + t; } // Celeron / Pentium sin el prefijo "Intel" (y con mayúscula inicial) if (/celeron/i.test(t)) { t = t.replace(/celeron/i, 'Celeron'); if (!/intel/i.test(t)) t = 'Intel ' + t; } if (/pentium/i.test(t)) { t = t.replace(/pentium/i, 'Pentium'); if (!/intel/i.test(t)) t = 'Intel ' + t; } return t; } // Arma "8GB" o "1TB" a partir del numero + unidad elegidos. function leerRam() { const n = document.getElementById('mRamNum').value.trim(); if (!n) return ''; return n + document.getElementById('mRamUnidad').value; } // Arma "256GB SSD", "1TB HDD", "512GB M.2", etc. function leerAlmacenamiento() { const n = document.getElementById('mAlmNum').value.trim(); if (!n) return ''; return n + document.getElementById('mAlmUnidad').value + ' ' + document.getElementById('mAlmTipo').value; } let productoEditando = null; function parseRam(ram) { const m = /^(\d+(?:\.\d+)?)\s*(GB|TB)$/i.exec((ram || '').trim()); return m ? { num: m[1], unidad: m[2].toUpperCase() } : { num: '', unidad: 'GB' }; } function parseAlmacenamiento(alm) { const s = (alm || '').trim(); let m = /^(\d+(?:\.\d+)?)\s*(GB|TB)\s+(SSD|M\.2|HDD)$/i.exec(s); if (m) return { num: m[1], unidad: m[2].toUpperCase(), tipo: /m\.2/i.test(m[3]) ? 'M.2' : m[3].toUpperCase() }; m = /^(\d+(?:\.\d+)?)\s*(GB|TB)$/i.exec(s); if (m) return { num: m[1], unidad: m[2].toUpperCase(), tipo: 'SSD' }; return { num: '', unidad: 'GB', tipo: 'SSD' }; } function parseGpu(gpu) { const m = /^(.*?)\s+(\d+(?:\.\d+)?)\s*GB$/i.exec((gpu || '').trim()); if (m) return { nombre: m[1], gb: m[2] }; return { nombre: gpu || '', gb: '' }; } // --- Foto del producto: subir (comprimida), o pegar URL --- let mFotoActual = ''; // data URL o URL externa que se guardara en el producto function actualizarFotoPreview() { const box = document.getElementById('mFotoPreviewBox'); const quitarBtn = document.getElementById('mQuitarFotoBtn'); if (mFotoActual) { box.innerHTML = 'Foto del producto'; quitarBtn.style.display = 'block'; } else { box.innerHTML = 'Sin foto'; quitarBtn.style.display = 'none'; } } function redimensionarImagen(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(reader.error); reader.onload = () => { const img = new Image(); img.onerror = () => reject(new Error('No se pudo leer la imagen')); img.onload = () => { const maxW = 500; const escala = Math.min(1, maxW / img.width); const w = Math.round(img.width * escala); const h = Math.round(img.height * escala); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); resolve(canvas.toDataURL('image/jpeg', 0.75)); }; img.src = reader.result; }; reader.readAsDataURL(file); }); } document.getElementById('mSubirFotoBtn').addEventListener('click', () => { document.getElementById('mFotoInput').click(); }); document.getElementById('mFotoInput').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { mFotoActual = await redimensionarImagen(file); document.getElementById('mFotoUrlInput').value = ''; actualizarFotoPreview(); } catch (err) { alertBonito('No se pudo procesar la imagen. Intenta con otra foto.'); } e.target.value = ''; }); document.getElementById('mFotoUrlInput').addEventListener('input', (e) => { mFotoActual = e.target.value.trim(); actualizarFotoPreview(); }); document.getElementById('mQuitarFotoBtn').addEventListener('click', () => { mFotoActual = ''; document.getElementById('mFotoUrlInput').value = ''; actualizarFotoPreview(); }); function abrirMercanciaModal(itemAEditar) { productoEditando = itemAEditar || null; document.getElementById('mCantidad').disabled = !!productoEditando; if (productoEditando) { const item = productoEditando; const categoria = item.categoria || 'Laptops'; const esLaptop = categoria === 'Laptops'; document.getElementById('mercanciaModalTitle').textContent = 'Editar producto (' + item.id + ')'; document.getElementById('mercanciaGuardarBtn').textContent = 'Guardar cambios'; document.getElementById('mNombre').value = (item.marca + ' ' + (item.modelo || '')).trim(); mFotoActual = item.fotoUrl || ''; document.getElementById('mFotoUrlInput').value = (mFotoActual && !mFotoActual.startsWith('data:')) ? mFotoActual : ''; actualizarFotoPreview(); document.getElementById('mCategoria').value = categoria; document.getElementById('mEspecificaciones').value = esLaptop ? '' : (item.procesador || ''); document.getElementById('mProcesador').value = esLaptop ? (item.procesador || '') : ''; document.getElementById('mNumeroSerie').value = esLaptop ? (item.numeroSerie || '') : ''; const ramP = parseRam(item.ram); document.getElementById('mRamNum').value = ramP.num; document.getElementById('mRamUnidad').value = ramP.unidad; const almP = parseAlmacenamiento(item.almacenamiento); document.getElementById('mAlmNum').value = almP.num; document.getElementById('mAlmUnidad').value = almP.unidad; document.getElementById('mAlmTipo').value = almP.tipo; document.getElementById('mEsTouch').checked = !!item.touch; document.getElementById('mTouchTipo').value = item.touch || 'Touch'; const gpuP = parseGpu(item.gpu); document.getElementById('mTieneGpu').checked = !!item.gpu; document.getElementById('mGpuNombre').value = gpuP.nombre; document.getElementById('mGpuGb').value = gpuP.gb; document.getElementById('mPrecio').value = item.precioBueno || ''; document.getElementById('mCantidad').value = '1'; document.getElementById('mAnaquel').value = esLaptop ? (item.anaquel || '') : ''; document.getElementById('mNivel').value = esLaptop ? (item.nivel || '') : ''; document.getElementById('mUbicacionLibre').value = esLaptop ? '' : (item.anaquel || ''); document.getElementById('mMonitorTamano').value = item.tamanoPulgadas || ''; document.getElementById('mMonitorBorde').value = item.tipoBorde || ''; document.getElementById('mMonitorUso').value = item.tipoUso || ''; const histBox = document.getElementById('mHistorialPrecios'); if (Array.isArray(item.historialPrecios) && item.historialPrecios.length > 0) { const filas = item.historialPrecios.slice().reverse().map(h => { const f = new Date(h.fecha).toLocaleDateString('es-MX'); return '
' + f + ': ' + money2(h.precioAnterior) + ' → ' + money2(h.precioNuevo) + ' (' + h.quien + ')
'; }).join(''); histBox.innerHTML = '
Historial de precios
' + filas; histBox.style.display = 'block'; } else { histBox.style.display = 'none'; } const esMonitorEdit = categoria === 'Monitores'; document.getElementById('mLaptopFields').style.display = esLaptop ? 'block' : 'none'; document.getElementById('mMonitorFields').style.display = esMonitorEdit ? 'block' : 'none'; document.getElementById('mEspecGeneral').style.display = esLaptop ? 'none' : 'block'; document.getElementById('mUbicacionLaptop').style.display = 'none'; document.getElementById('mUbicacionLibreGroup').style.display = esLaptop ? 'none' : 'block'; document.getElementById('mGpuGroup').style.display = item.gpu ? 'flex' : 'none'; document.getElementById('mercanciaModal').style.display = 'flex'; document.getElementById('mNombre').focus(); if (esLaptop) actualizarVistaPreviaEtiqueta(); return; } document.getElementById('mercanciaModalTitle').textContent = 'Agregar mercancía nueva'; document.getElementById('mercanciaGuardarBtn').textContent = 'Guardar producto'; document.getElementById('mNombre').value = ''; mFotoActual = ''; document.getElementById('mFotoUrlInput').value = ''; actualizarFotoPreview(); document.getElementById('mCategoria').value = 'Laptops'; document.getElementById('mEspecificaciones').value = ''; document.getElementById('mProcesador').value = ''; document.getElementById('mNumeroSerie').value = ''; document.getElementById('mRamNum').value = ''; document.getElementById('mRamUnidad').value = 'GB'; document.getElementById('mAlmNum').value = ''; document.getElementById('mAlmUnidad').value = 'GB'; document.getElementById('mAlmTipo').value = 'SSD'; document.getElementById('mEsTouch').checked = false; document.getElementById('mTouchTipo').value = 'Touch'; document.getElementById('mTieneGpu').checked = false; document.getElementById('mGpuNombre').value = ''; document.getElementById('mGpuGb').value = ''; document.getElementById('mPrecio').value = ''; document.getElementById('mCantidad').value = '1'; document.getElementById('mAnaquel').value = ultimaUbicacion.anaquel; document.getElementById('mNivel').value = ultimaUbicacion.nivel; document.getElementById('mUbicacionLibre').value = ''; document.getElementById('mMonitorTamano').value = ''; document.getElementById('mMonitorBorde').value = ''; document.getElementById('mMonitorUso').value = ''; document.getElementById('mHistorialPrecios').style.display = 'none'; document.getElementById('mLaptopFields').style.display = 'block'; document.getElementById('mMonitorFields').style.display = 'none'; document.getElementById('mEspecGeneral').style.display = 'none'; document.getElementById('mUbicacionLaptop').style.display = 'none'; document.getElementById('mUbicacionLibreGroup').style.display = 'none'; document.getElementById('mTouchTypeGroup').style.display = 'none'; document.getElementById('mGpuGroup').style.display = 'none'; document.getElementById('mEtiquetaPreview').innerHTML = 'Llena los datos para ver la vista previa'; document.getElementById('mercanciaModal').style.display = 'flex'; document.getElementById('mNombre').focus(); } function escapeHtmlPreview(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>'); } // Arma en vivo, solo para laptops, la vista previa de como se veran los datos en la // etiqueta (nombre grande + procesador + "RAM | Almacenamiento" + touch/gpu si aplican). function actualizarVistaPreviaEtiqueta() { const preview = document.getElementById('mEtiquetaPreview'); if (!preview) return; const nombre = document.getElementById('mNombre').value.trim(); const anaquel = document.getElementById('mAnaquel').value.trim(); const nivel = document.getElementById('mNivel').value.trim(); const procesador = document.getElementById('mProcesador').value.trim(); const ram = leerRam(); const almacenamiento = leerAlmacenamiento(); const touch = document.getElementById('mEsTouch').checked ? (document.getElementById('mTouchTipo').value.trim() || 'Touch') : ''; const gpuNombre = document.getElementById('mGpuNombre').value.trim(); const gpuGb = (document.getElementById('mGpuGb').value || '').replace(/[^0-9.]/g, ''); const gpu = document.getElementById('mTieneGpu').checked ? (gpuNombre + (gpuGb ? ' ' + gpuGb + 'GB' : '')).trim() : ''; const specs = construirSpecsEtiqueta({ procesador, ram, almacenamiento, touch, gpu }); if (!nombre && !specs) { preview.innerHTML = 'Llena los datos para ver la vista previa'; return; } const idPreview = (anaquel && nivel) ? ('A' + escapeHtmlPreview(anaquel) + '-' + String(nivel).padStart(2, '0') + '-XX') : 'El ID se genera al guardar'; let html = '
' + idPreview + '
'; html += '
' + (nombre ? escapeHtmlPreview(nombre) : '(sin nombre)') + '
'; if (specs) { html += specs.split('\n').map(l => '
' + escapeHtmlPreview(l) + '
').join(''); } preview.innerHTML = html; } document.getElementById('mCategoria').addEventListener('change', (e) => { const esLaptop = e.target.value === 'Laptops'; const esMonitor = e.target.value === 'Monitores'; document.getElementById('mLaptopFields').style.display = esLaptop ? 'block' : 'none'; document.getElementById('mMonitorFields').style.display = esMonitor ? 'block' : 'none'; document.getElementById('mEspecGeneral').style.display = esLaptop ? 'none' : 'block'; document.getElementById('mUbicacionLaptop').style.display = 'none'; document.getElementById('mUbicacionLibreGroup').style.display = esLaptop ? 'none' : 'block'; if (esLaptop) actualizarVistaPreviaEtiqueta(); }); (function poblarTamanosMonitor() { const sel = document.getElementById('mMonitorTamano'); for (let n = 17; n <= 27; n++) { const opt = document.createElement('option'); opt.value = String(n); opt.textContent = n + ' pulgadas'; sel.appendChild(opt); } })(); document.getElementById('mEsTouch').addEventListener('change', (e) => { document.getElementById('mTouchTypeGroup').style.display = e.target.checked ? 'block' : 'none'; actualizarVistaPreviaEtiqueta(); }); document.getElementById('mTieneGpu').addEventListener('change', (e) => { document.getElementById('mGpuGroup').style.display = e.target.checked ? 'flex' : 'none'; actualizarVistaPreviaEtiqueta(); }); document.getElementById('mProcesador').addEventListener('blur', (e) => { e.target.value = normalizarProcesador(e.target.value); actualizarVistaPreviaEtiqueta(); }); ['mNombre', 'mAnaquel', 'mNivel', 'mProcesador', 'mRamNum', 'mAlmNum', 'mTouchTipo', 'mGpuNombre', 'mGpuGb'].forEach(id => { document.getElementById(id).addEventListener('input', actualizarVistaPreviaEtiqueta); }); ['mRamUnidad', 'mAlmUnidad', 'mAlmTipo'].forEach(id => { document.getElementById(id).addEventListener('change', actualizarVistaPreviaEtiqueta); }); document.getElementById('mercanciaCancelBtn').addEventListener('click', () => { document.getElementById('mercanciaModal').style.display = 'none'; productoEditando = null; }); document.getElementById('mercanciaGuardarBtn').addEventListener('click', async () => { const nombreProducto = document.getElementById('mNombre').value.trim(); if (!nombreProducto) { alertBonito('Escribe el nombre del producto.'); return; } const precio = parseFloat((document.getElementById('mPrecio').value || '').replace(/[^0-9.]/g, '')) || 0; const cantidad = Math.max(1, parseInt(document.getElementById('mCantidad').value, 10) || 1); const categoria = document.getElementById('mCategoria').value; const esLaptop = categoria === 'Laptops'; const anaquelInput = document.getElementById('mAnaquel').value.trim(); const nivelInput = document.getElementById('mNivel').value.trim(); const ubicacionLibre = document.getElementById('mUbicacionLibre').value.trim(); let especificaciones = '', ram = '', almacenamiento = '', touch = '', gpu = '', numeroSerie = ''; let tamanoPulgadas = '', tipoBorde = '', tipoUso = ''; if (esLaptop) { especificaciones = normalizarProcesador(document.getElementById('mProcesador').value.trim()); numeroSerie = document.getElementById('mNumeroSerie').value.trim(); ram = leerRam(); almacenamiento = leerAlmacenamiento(); if (document.getElementById('mEsTouch').checked) { touch = document.getElementById('mTouchTipo').value.trim() || 'Touch'; } if (document.getElementById('mTieneGpu').checked) { const nombreGpu = document.getElementById('mGpuNombre').value.trim(); const gb = (document.getElementById('mGpuGb').value || '').replace(/[^0-9.]/g, ''); gpu = (nombreGpu + (gb ? ' ' + gb + 'GB' : '')).trim(); } } else if (categoria === 'Monitores') { tamanoPulgadas = document.getElementById('mMonitorTamano').value; tipoBorde = document.getElementById('mMonitorBorde').value; tipoUso = document.getElementById('mMonitorUso').value; const notas = document.getElementById('mEspecificaciones').value.trim(); especificaciones = [ tamanoPulgadas ? (tamanoPulgadas + ' pulgadas') : '', tipoBorde, tipoUso, notas, ].filter(Boolean).join(' · '); } else { especificaciones = document.getElementById('mEspecificaciones').value.trim(); } // las laptops ya no piden ubicacion (se genera un ID aleatorio); el resto de la // mercancia sigue usando un texto libre de ubicacion, ya que esta repartida por la bodega const anaquelFinal = esLaptop ? '' : ubicacionLibre; const nivelFinal = ''; if (productoEditando) { const item = productoEditando; item.marca = nombreProducto; item.modelo = ''; item.categoria = categoria; item.procesador = especificaciones; item.numeroSerie = numeroSerie; item.ram = ram; item.almacenamiento = almacenamiento; item.touch = touch; item.gpu = gpu; item.tamanoPulgadas = tamanoPulgadas; item.tipoBorde = tipoBorde; item.tipoUso = tipoUso; item.fotoUrl = mFotoActual; // las laptops ya no tienen campo de ubicacion en el formulario: se deja tal cual estaba if (!esLaptop) { item.anaquel = anaquelFinal; item.nivel = nivelFinal; } const precioInputRaw = document.getElementById('mPrecio').value.trim(); if (precioInputRaw && precio !== item.precioBueno) { if (!Array.isArray(item.historialPrecios)) item.historialPrecios = []; item.historialPrecios.push({ precioAnterior: item.precioBueno, precioNuevo: precio, fecha: new Date().toISOString(), quien: currentSession ? currentSession.nombre : 'N/D' }); item.precioBueno = precio; item.precioCosmetico = precio; } await guardarCorreccion(item); registrarAuditoria('Editó producto', item.id + ' — ' + (item.marca + ' ' + item.modelo).trim()); updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); if (currentItem && currentItem.id === item.id) render(item); document.getElementById('mercanciaModal').style.display = 'none'; productoEditando = null; const quiereReimprimir = await confirmarBonito('Producto actualizado.', '¿Reimprimir la etiqueta con los datos corregidos?'); if (quiereReimprimir) { const etiqueta = [{ id: item.id, nombre: (item.marca + ' ' + item.modelo).trim(), specs: esLaptop ? construirSpecsEtiqueta(item) : (item.procesador || ''), cantidad: 1, }]; const ok = await generarEtiquetasPDF(etiqueta); if (ok) await marcarComoEtiquetadas([item.id]); } return; } let extras = []; try { extras = await getListFromStorage(EXTRA_INV_KEY); } catch (e) { extras = []; } const ahora = new Date().toISOString(); // Las laptops usan un contador secuencial compartido (A-000001, A-000002...) en vez de // un ID aleatorio, para llevar la cuenta de cuantas se han registrado en total. Se // reservan de una vez todos los IDs que hagan falta para este lote (si "cantidad" es // mayor a 1), leyendo y guardando el contador una sola vez. const idsLaptopReservados = esLaptop ? await reservarIdsLaptopSecuenciales(cantidad) : []; const nuevos = []; for (let i = 0; i < cantidad; i++) { const id = esLaptop ? idsLaptopReservados[i] : generarIdAleatorioGeneral(); const item = { id, anaquel: anaquelFinal, nivel: nivelFinal, categoria, marca: nombreProducto, modelo: '', procesador: especificaciones, numeroSerie, ram, almacenamiento, touch, gpu, tamanoPulgadas, tipoBorde, tipoUso, fotoUrl: mFotoActual, precioBueno: precio, precioCosmetico: precio, etiquetado: false, fechaAgregado: ahora, agregadoPor: currentSession ? currentSession.nombre : 'N/D', estadoEquipo: 'pendiente', }; nuevos.push(item); DATA.push(item); INDEX[id.toUpperCase()] = item; } extras = extras.concat(nuevos); try { await setListToStorage(EXTRA_INV_KEY, extras); } catch (e) {} // guarda un registro de productividad (quien, cuantos, cuando, y que productos) para el reporte semanal de técnicos try { const registros = await getListFromStorage('registro_tecnicos'); registros.push({ quien: currentSession ? currentSession.nombre : 'N/D', cantidad: nuevos.length, fecha: ahora, productos: nuevos.map(it => ({ id: it.id, nombre: (it.marca + ' ' + it.modelo).trim() })), origen: 'manual', }); await setListToStorage('registro_tecnicos', registros); } catch (e) {} updateLoadedCount(); document.getElementById('mercanciaModal').style.display = 'none'; const idsResumen = cantidad === 1 ? nuevos[0].id : nuevos[0].id + ' al ' + nuevos[nuevos.length - 1].id; alertBonito( (cantidad === 1 ? 'Producto agregado con el ID ' + idsResumen + '.' : cantidad + ' piezas agregadas con IDs del ' + idsResumen + '.') + '\n\nQuedó pendiente de etiquetar — puedes generar su etiqueta desde "Generar etiquetas".' ); }); function agregarMercanciaFlow() { abrirMercanciaModal(); } // --- Generar etiquetas de codigo de barras (formato 51x25mm, igual que el inventario original) --- function agregarFilaEtiqueta(id, nombre, specs, cantidad) { const lista = document.getElementById('etiquetasLista'); const emptyMsg = lista.querySelector('.etiquetas-empty-msg'); if (emptyMsg) emptyMsg.remove(); const row = document.createElement('div'); row.className = 'etiqueta-row'; row.innerHTML = `
`; lista.appendChild(row); } const HISTORIAL_ETIQUETAS_KEY = 'historial_etiquetas'; async function marcarComoEtiquetadas(ids) { if (!ids || ids.length === 0) return; const idSet = new Set(ids.map(x => (x || '').toUpperCase())); let extras = []; try { extras = await getListFromStorage(EXTRA_INV_KEY); } catch (e) { return; } let changed = false; const marcadosAhora = []; extras.forEach(it => { if (idSet.has((it.id || '').toUpperCase()) && !it.etiquetado) { it.etiquetado = true; changed = true; marcadosAhora.push(it); } }); if (changed) { try { await setListToStorage(EXTRA_INV_KEY, extras); } catch (e) {} } // Historial de etiquetas ya confirmadas como impresas — sirve para buscarlas y // reimprimirlas después si hiciera falta, sin depender de que sigan en "pendientes". if (marcadosAhora.length > 0) { try { const historial = await getListFromStorage(HISTORIAL_ETIQUETAS_KEY); const ahora = new Date().toISOString(); const quien = currentSession ? currentSession.nombre : 'N/D'; marcadosAhora.forEach(it => { historial.push({ id: it.id, nombre: (it.marca + ' ' + (it.modelo || '')).trim(), specs: construirSpecsEtiqueta(it), fecha: ahora, quien, }); }); // se guardan nada mas los ultimos 500, para que esto no crezca sin limite await setListToStorage(HISTORIAL_ETIQUETAS_KEY, historial.slice(-500)); } catch (e) {} } } async function getPendientesEtiquetado() { let extras = []; try { extras = await getListFromStorage(EXTRA_INV_KEY); } catch (e) { extras = []; } return extras .filter(it => it && !it.etiquetado) .sort((a, b) => { // primero por fecha (lo mas nuevo arriba), y si varias se agregaron juntas al // mismo tiempo (mismo lote), por ID en orden natural — para que no salgan revueltas const diffFecha = new Date(b.fechaAgregado || 0) - new Date(a.fechaAgregado || 0); if (diffFecha !== 0) return diffFecha; return compararIds(a.id, b.id); }); } async function renderPendientesEtiquetado() { const pendientes = await getPendientesEtiquetado(); const cont = document.getElementById('etiquetasPendientesLista'); const btn = document.getElementById('etiquetasImprimirPendientesBtn'); if (pendientes.length === 0) { cont.innerHTML = '

No hay mercancía nueva pendiente de etiquetar.

'; btn.style.display = 'none'; } else { cont.innerHTML = pendientes.map(it => { const nombre = (it.marca + ' ' + it.modelo).trim(); const fechaStr = it.fechaAgregado ? new Date(it.fechaAgregado).toLocaleString('es-MX', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''; return '
' + it.id + '' + nombre + '' + fechaStr + '
'; }).join(''); btn.style.display = 'block'; btn.textContent = 'Imprimir todas las pendientes (' + pendientes.length + ')'; } return pendientes; } document.getElementById('etiquetasImprimirPendientesBtn').addEventListener('click', async () => { const pendientes = await getPendientesEtiquetado(); if (pendientes.length === 0) return; const etiquetas = pendientes.map(it => ({ id: it.id, nombre: (it.marca + ' ' + it.modelo).trim(), specs: construirSpecsEtiqueta(it), cantidad: 1, })); const ok = await generarEtiquetasPDF(etiquetas); if (!ok) return; await marcarComoEtiquetadas(pendientes.map(it => it.id)); await renderPendientesEtiquetado(); }); // --- Historial de etiquetas ya confirmadas como impresas: se puede buscar cualquiera y // reimprimirla, sin depender de que siga en "pendientes". --- async function renderHistorialEtiquetas(filtro) { let historial = await getListFromStorage(HISTORIAL_ETIQUETAS_KEY); historial = historial.slice().sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); const texto = (filtro || '').trim().toLowerCase(); if (texto) { historial = historial.filter(h => (h.id || '').toLowerCase().includes(texto) || (h.nombre || '').toLowerCase().includes(texto) ); } const cont = document.getElementById('historialEtiquetasLista'); document.getElementById('historialEtiquetasCount').textContent = historial.length + ' etiqueta(s)'; if (historial.length === 0) { cont.innerHTML = '

No hay etiquetas en el historial todavía.

'; return; } cont.innerHTML = historial.map((h, i) => { const fechaStr = h.fecha ? new Date(h.fecha).toLocaleString('es-MX', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''; return `
${escapeHtmlPreview(h.nombre || h.id)} ${h.id} · ${fechaStr}${h.quien ? ' · ' + escapeHtmlPreview(h.quien) : ''}
`; }).join(''); cont.dataset.datos = JSON.stringify(historial); } document.getElementById('verHistorialEtiquetasBtn').addEventListener('click', async () => { document.getElementById('historialEtiquetasSearch').value = ''; await renderHistorialEtiquetas(''); document.getElementById('historialEtiquetasModal').style.display = 'flex'; }); document.getElementById('historialEtiquetasSearch').addEventListener('input', debounce((e) => { renderHistorialEtiquetas(e.target.value); }, 200)); document.getElementById('historialEtiquetasCerrarBtn').addEventListener('click', () => { document.getElementById('historialEtiquetasModal').style.display = 'none'; }); document.getElementById('historialEtiquetasLista').addEventListener('click', async (e) => { const btn = e.target.closest('.historial-etiqueta-reimprimir'); if (!btn) return; const datos = JSON.parse(document.getElementById('historialEtiquetasLista').dataset.datos || '[]'); const h = datos[parseInt(btn.dataset.idx, 10)]; if (!h) return; await generarEtiquetasPDF([{ id: h.id, nombre: h.nombre, specs: h.specs || '', cantidad: 1 }]); }); function actualizarEtiquetasEmptyMsg() { const lista = document.getElementById('etiquetasLista'); if (!lista.querySelector('.etiqueta-row')) { lista.innerHTML = '

Todavía no has agregado ninguna etiqueta.

'; } } async function abrirEtiquetasModal() { document.getElementById('etiquetaIdInput').value = ''; document.getElementById('etiquetaCantInput').value = '1'; document.getElementById('etiquetasLista').innerHTML = '

Todavía no has agregado ninguna etiqueta.

'; document.getElementById('etiquetasModal').style.display = 'flex'; await renderPendientesEtiquetado(); document.getElementById('etiquetaIdInput').focus(); } function generarEtiquetasFlow() { abrirEtiquetasModal(); } document.getElementById('etiquetaBuscarBtn').addEventListener('click', () => { const idInput = document.getElementById('etiquetaIdInput'); const idRaw = idInput.value.trim().toUpperCase(); if (!idRaw) { alertBonito('Escribe o escanea un ID.'); return; } const idU = INDEX[idRaw] ? idRaw : normalizarCodigoEscaneado(idRaw); const cantidad = Math.max(1, parseInt(document.getElementById('etiquetaCantInput').value, 10) || 1); const item = INDEX[idU]; let nombre, specs; if (item) { nombre = (item.marca + ' ' + item.modelo).trim(); specs = construirSpecsEtiqueta(item); } else { alertBonito('No se encontro el ID ' + idU + ' en el inventario. Puedes escribir los datos manualmente para esta etiqueta.'); nombre = ''; specs = ''; } agregarFilaEtiqueta(idU, nombre, specs, cantidad); idInput.value = ''; document.getElementById('etiquetaCantInput').value = '1'; idInput.focus(); }); document.getElementById('etiquetaIdInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('etiquetaBuscarBtn').click(); } }); document.getElementById('etiquetasLista').addEventListener('click', (e) => { const btn = e.target.closest('.eq-remove-btn'); if (!btn) return; btn.closest('.etiqueta-row').remove(); actualizarEtiquetasEmptyMsg(); }); document.getElementById('etiquetasCancelBtn').addEventListener('click', () => { document.getElementById('etiquetasModal').style.display = 'none'; }); document.getElementById('etiquetasGenerarBtn').addEventListener('click', async () => { const filas = Array.from(document.querySelectorAll('#etiquetasLista .etiqueta-row')); if (filas.length === 0) { alertBonito('Agrega al menos una etiqueta a la lista.'); return; } const etiquetas = filas.map(row => ({ id: row.querySelector('.eq-id').value.trim() || 'S/ID', nombre: row.querySelector('.eq-nombre').value.trim(), specs: row.querySelector('.eq-specs').value.trim(), cantidad: Math.max(1, parseInt(row.querySelector('.eq-cant').value, 10) || 1), })).sort((a, b) => compararIds(a.id, b.id)); const ok = await generarEtiquetasPDF(etiquetas); if (!ok) return; await marcarComoEtiquetadas(etiquetas.map(e => e.id)); await renderPendientesEtiquetado(); document.getElementById('etiquetasModal').style.display = 'none'; }); function cargarScriptDesdeUrl(url) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.onload = () => resolve(); script.onerror = () => reject(new Error('No se pudo cargar ' + url)); document.head.appendChild(script); }); } // Intenta varias fuentes en orden por si una CDN especifica esta bloqueada en la red del negocio. async function cargarScriptConRespaldos(urls) { let ultimoError = null; for (const url of urls) { try { await cargarScriptDesdeUrl(url); return true; } catch (e) { ultimoError = e; } } throw ultimoError || new Error('No se pudo cargar ninguna de las fuentes'); } async function asegurarLibreriasEtiquetas() { if (typeof window.jspdf === 'undefined') { await cargarScriptConRespaldos([ 'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js', 'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', ]); } if (typeof JsBarcode === 'undefined') { await cargarScriptConRespaldos([ 'https://cdn.jsdelivr.net/npm/jsbarcode@3.11.5/dist/JsBarcode.all.min.js', 'https://unpkg.com/jsbarcode@3.11.5/dist/JsBarcode.all.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/JsBarcode/3.11.5/JsBarcode.all.min.js', ]); } } async function generarEtiquetasPDF(etiquetas) { if (typeof window.jspdf === 'undefined' || typeof JsBarcode === 'undefined') { try { await asegurarLibreriasEtiquetas(); } catch (e) { alertBonito('No se pudieron cargar las librerías para generar el PDF (se intentó con varias fuentes). Revisa tu conexión a internet e intenta de nuevo.'); return false; } } if (typeof window.jspdf === 'undefined' || typeof JsBarcode === 'undefined') { alertBonito('No se pudieron cargar las librerías para generar el PDF. Intenta de nuevo en un momento.'); return false; } try { const { jsPDF } = window.jspdf; const LW = 57, LH = 40; // mm, tamaño real de las etiquetas de tu impresora const doc = new jsPDF({ unit: 'mm', format: [LW, LH], orientation: 'landscape' }); let first = true; // Tamaños "naturales" de arranque segun cuantas lineas de specs haya (ya envueltas). // Son solo el punto de partida — despues se escala TODO (barra, folio, nombre y specs) // hacia arriba o hacia abajo para aprovechar el alto real de la etiqueta al maximo, // sin que nunca quede ni amontonado ni con espacio vacio de sobra. function specFontPara(n) { return n <= 2 ? 8.5 : (n === 3 ? 7.5 : (n === 4 ? 6.6 : 5.8)); } function specAltoPara(n) { return n <= 2 ? 4.4 : (n === 3 ? 3.9 : (n === 4 ? 3.4 : 3.0)); } etiquetas.forEach(({ id, nombre, specs, cantidad }) => { const specLines = (specs || '').split('\n').map(s => s.trim()).filter(Boolean); for (let i = 0; i < cantidad; i++) { if (!first) doc.addPage([LW, LH], 'landscape'); first = false; let barcodeData = null; try { const canvas = document.createElement('canvas'); JsBarcode(canvas, id, { format: 'CODE128', displayValue: false, margin: 0, height: 60 }); barcodeData = canvas.toDataURL('image/png'); } catch (e) {} function envolverSpecs(fontSize) { doc.setFont('helvetica', 'bold'); doc.setFontSize(fontSize); const out = []; specLines.forEach(linea => { out.push(...doc.splitTextToSize(linea, LW - 4)); }); return out; } function envolverNombre(fontSize) { doc.setFont('helvetica', 'bold'); doc.setFontSize(fontSize); return doc.splitTextToSize(nombre || '', LW - 4); } // --- Paso 1: tamaño "natural" (sin escalar todavia) --- let barH = 12, barGap = 2.6, idLineH = 4, idFont = 9; let nombreGap = nombre ? 2 : 0, specsGap = 2; let specFontSize = specFontPara(Math.max(1, specLines.length)); let specWrapped = envolverSpecs(specFontSize); if (specWrapped.length !== specLines.length) { specFontSize = specFontPara(Math.max(1, specWrapped.length)); specWrapped = envolverSpecs(specFontSize); } let specLineHeight = specAltoPara(Math.max(1, specWrapped.length)); let nombreFontSize = 13; let nombreLineas = nombre ? envolverNombre(nombreFontSize) : []; while (nombre && nombreLineas.length > 1 && nombreFontSize > 8) { nombreFontSize -= 0.8; nombreLineas = envolverNombre(nombreFontSize); } let nombreLineH = nombre ? Math.max(4, nombreFontSize * 0.42) : 0; function calcularTotalH() { return barH + barGap + idLineH + (nombre ? (nombreGap + nombreLineH) : 0) + specsGap + specWrapped.length * specLineHeight; } // --- Paso 2: escalar TODO junto para aprovechar el alto real de la etiqueta — // si sobra espacio agranda letra y barra; si no cabe, las encoge — siempre // volviendo a envolver el texto en cada paso (el ancho tambien cambia con la // letra) para que nunca quede cortado, encimado ni desperdiciando espacio. const alturaObjetivo = LH - 3; let intentos = 0; while (intentos < 10) { const totalH = calcularTotalH(); if (Math.abs(totalH - alturaObjetivo) < 0.7) break; let escala = alturaObjetivo / totalH; escala = Math.max(0.85, Math.min(1.25, escala)); // pasos suaves para que converja bien specFontSize = Math.max(4.2, Math.min(13, specFontSize * escala)); specLineHeight = Math.max(2.3, Math.min(6, specLineHeight * escala)); barH = Math.max(9, Math.min(18, barH * escala)); barGap = Math.max(1.6, Math.min(4, barGap * escala)); idLineH = Math.max(3, Math.min(6, idLineH * escala)); idFont = Math.max(7, Math.min(12.5, idFont * escala)); specsGap = Math.max(1.2, Math.min(3.4, specsGap * escala)); specWrapped = envolverSpecs(specFontSize); if (nombre) { nombreFontSize = Math.max(8, Math.min(22, nombreFontSize * escala)); nombreLineH = Math.max(4, nombreFontSize * 0.42); nombreGap = Math.max(1.2, Math.min(3.4, nombreGap * escala)); nombreLineas = envolverNombre(nombreFontSize); } intentos++; } // --- Paso 3: seguro para el nombre — si al agrandar la letra el nombre ya no // cabe en una sola linea, se encoge de vuelta hasta que quepa. Es necesario // porque solo se dibuja la primera linea del nombre; si se dejara envuelto a 2 // lineas sin ajustar el alto, la segunda linea se perderia o se encimaria con // los specs de abajo. while (nombre && nombreLineas.length > 1 && nombreFontSize > 6) { nombreFontSize -= 0.5; nombreLineH = Math.max(3.4, nombreFontSize * 0.42); nombreLineas = envolverNombre(nombreFontSize); } const totalH = calcularTotalH(); let y = Math.max(1.5, (LH - totalH) / 2); if (barcodeData) { const barW = LW - 10; doc.addImage(barcodeData, 'PNG', (LW - barW) / 2, y, barW, barH); } y += barH + barGap; doc.setFont('helvetica', 'bold'); doc.setFontSize(idFont); doc.text(id, LW / 2, y, { align: 'center' }); y += idLineH; if (nombre) { y += nombreGap; doc.setFont('helvetica', 'bold'); doc.setFontSize(nombreFontSize); doc.text(nombreLineas[0] || '', LW / 2, y, { align: 'center' }); y += nombreLineH; } y += specsGap; doc.setFont('helvetica', 'bold'); doc.setFontSize(specFontSize); specWrapped.forEach(linea => { doc.text(linea, LW / 2, y, { align: 'center' }); y += specLineHeight; }); } }); const confirmado = await mostrarPreviaImpresion(doc, 'Vista previa de la etiqueta', 'Etiquetas_' + new Date().toISOString().slice(0, 10) + '.pdf', true); return confirmado; } catch (e) { alertBonito('Ocurrió un error generando el PDF de etiquetas. Intenta de nuevo.'); return false; } } // Muestra el PDF en una ventanita pequeña dentro de la misma app (en vez de descargarlo y // que se abra en el visor de PDF del sistema a pantalla completa) — así no se pierde el // modo kiosko en Windows al querer imprimir una etiqueta, un recibo o una cotización. let etiquetaPreviewBlobUrl = null; let etiquetaPreviewNombreArchivo = 'documento.pdf'; let etiquetaPreviewResolver = null; let etiquetaPreviewPedirConfirmacion = false; // pedirConfirmacion=true (solo lo usan las etiquetas) hace que, al cerrar la vista previa, // se pregunte de verdad si se imprimió bien — así el folio nunca se cierra ni se borra de // "pendientes" solo porque se abrió la vista previa; se cierra hasta que la persona // confirma que la etiqueta sí salió. Para recibos u otros usos no se pregunta nada // (se resuelve en true de una vez, mismo comportamiento de siempre). function mostrarPreviaImpresion(doc, titulo, nombreArchivo, pedirConfirmacion) { if (etiquetaPreviewBlobUrl) { try { URL.revokeObjectURL(etiquetaPreviewBlobUrl); } catch (e) {} } const blob = doc.output('blob'); etiquetaPreviewBlobUrl = URL.createObjectURL(blob); etiquetaPreviewNombreArchivo = nombreArchivo || 'documento.pdf'; document.getElementById('etiquetaPreviewTitulo').textContent = titulo || 'Vista previa'; document.getElementById('etiquetaPreviewFrame').src = etiquetaPreviewBlobUrl; document.getElementById('etiquetaPreviewModal').style.display = 'flex'; etiquetaPreviewPedirConfirmacion = !!pedirConfirmacion; return new Promise((resolve) => { etiquetaPreviewResolver = resolve; }); } document.getElementById('etiquetaPreviewImprimirBtn').addEventListener('click', () => { const frame = document.getElementById('etiquetaPreviewFrame'); try { frame.contentWindow.focus(); frame.contentWindow.print(); } catch (e) { alertBonito('No se pudo abrir la impresión automáticamente. Usa el botón "Descargar PDF" y luego imprímelo desde ahí.'); } }); document.getElementById('etiquetaPreviewDescargarBtn').addEventListener('click', () => { if (!etiquetaPreviewBlobUrl) return; const a = document.createElement('a'); a.href = etiquetaPreviewBlobUrl; a.download = etiquetaPreviewNombreArchivo; document.body.appendChild(a); a.click(); a.remove(); }); document.getElementById('etiquetaPreviewCerrarBtn').addEventListener('click', async () => { document.getElementById('etiquetaPreviewModal').style.display = 'none'; document.getElementById('etiquetaPreviewFrame').src = 'about:blank'; let resultado = true; if (etiquetaPreviewPedirConfirmacion) { resultado = await confirmarBonito( 'Si la etiqueta no salió bien (se atoró, salió en blanco, cambiaste de opinión), di que no — se queda guardada en "pendientes" para volver a intentarlo cuando quieras.', '¿Se imprimió correctamente?' ); } if (etiquetaPreviewResolver) { etiquetaPreviewResolver(resultado); etiquetaPreviewResolver = null; } }); function newSaleId() { return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); } // older sale records (saved before "cancelar venta" existed) may not have a saleId yet. // this backfills any missing ones and persists them, so every sale can always be cancelled. async function getSalesWithIds(key) { let list = []; try { list = await getListFromStorage(key); } catch (e) { list = []; } if (!Array.isArray(list)) list = []; let changed = false; list.forEach(e => { try { if (e && !e.saleId) { e.saleId = newSaleId(); changed = true; } } catch (err) { /* ignora un registro corrupto sin tumbar el resto */ } }); if (changed) { try { await setListToStorage(key, list); } catch (e) {} } return list; } // --- reinicio automático del periodo de ventas cada 6 días --- const PERIODO_DIAS = 6; const PERIODO_KEY = 'periodo_ventas_inicio'; async function getPeriodoInicio() { if (hasSupabase) { try { const v = await supaGet(PERIODO_KEY); if (v) return v; } catch (e) {} } if (hasSharedStorage) { try { const r = await window.storage.get(PERIODO_KEY, true); return r ? r.value : null; } catch (e) {} } try { return localStorage.getItem(PERIODO_KEY); } catch (e) { return null; } } async function setPeriodoInicio(iso) { if (hasSupabase) { try { await supaSet(PERIODO_KEY, iso); return; } catch (e) {} } if (hasSharedStorage) { try { await window.storage.set(PERIODO_KEY, iso, true); return; } catch (e) {} } try { localStorage.setItem(PERIODO_KEY, iso); } catch (e) {} } async function checkAndResetPeriodoVentas() { let inicio = await getPeriodoInicio(); if (!inicio) { await setPeriodoInicio(new Date().toISOString()); return; } const diasTranscurridos = (Date.now() - new Date(inicio).getTime()) / 86400000; if (diasTranscurridos < PERIODO_DIAS) return; // archivar el periodo actual antes de limpiarlo, para no perder el historial const invSales = await getListFromStorage('ventas_inventario'); const extSales = await getListFromStorage('ventas_externas'); if (invSales.length || extSales.length) { const historico = await getListFromStorage('ventas_historico_periodos'); historico.push({ inicio, fin: new Date().toISOString(), ventas_inventario: invSales, ventas_externas: extSales, }); await setListToStorage('ventas_historico_periodos', historico); } await setListToStorage('ventas_inventario', []); await setListToStorage('ventas_externas', []); await setPeriodoInicio(new Date().toISOString()); } async function logCustomSale(entry, facturaYaDecidida) { entry.saleId = newSaleId(); entry.facturaSolicitada = (typeof facturaYaDecidida === 'boolean') ? facturaYaDecidida : await confirmarBonito('Esto activa el recordatorio en "Facturas pendientes".', '¿El cliente pidió factura para esta venta?'); list.push(entry); await setListToStorage('ventas_externas', list); return entry.saleId; } async function logInventorySale(entry, facturaYaDecidida) { entry.saleId = newSaleId(); entry.facturaSolicitada = (typeof facturaYaDecidida === 'boolean') ? facturaYaDecidida : await confirmarBonito('Esto activa el recordatorio en "Facturas pendientes".', '¿El cliente pidió factura para esta venta?'); entry.facturaEmitida = false; const list = await getListFromStorage('ventas_inventario'); list.push(entry); await setListToStorage('ventas_inventario', list); return entry.saleId; } async function removeInventorySaleLog(id) { const list = await getListFromStorage('ventas_inventario'); const removed = list.find(e => e && e.id === id) || null; const filtered = list.filter(e => e.id !== id); await setListToStorage('ventas_inventario', filtered); return removed; } async function reversarCajaDeVenta(entry, concepto) { if (!entry || entry.metodoPago !== 'Efectivo') return; const estado = await getCajaEstado(); if (!estado.abierta) return; // reverse the original movements: money that came in goes back out, and change given comes back in await addCajaMovimiento('salida', 'Cancelación de venta: ' + concepto + ' (devuelto)', entry.recibido); if (entry.cambio > 0) { await addCajaMovimiento('entrada', 'Cancelación de venta: ' + concepto + ' (cambio recuperado)', entry.cambio); } } async function cancelarVentaInventario(saleId, saltarConfirmacion) { const list = await getSalesWithIds('ventas_inventario'); const entry = list.find(e => e && e.saleId === saleId); if (!entry) return false; if (entry.estado === 'cancelada') { if (!saltarConfirmacion) alertBonito('Esta venta ya estaba cancelada.'); return false; } const resumen = entry.marca + ' ' + entry.modelo + ' — ' + money(entry.precio) + ' (' + entry.vendedor + ')'; if (!saltarConfirmacion && !(await confirmarBonito(resumen + '\n\nEl equipo volverá a marcarse como disponible.', '¿Cancelar esta venta?'))) return false; soldSet.delete(entry.id); await saveSoldSet(); entry.estado = 'cancelada'; entry.canceladaEn = new Date().toISOString(); await setListToStorage('ventas_inventario', list); await reversarCajaDeVenta(entry, entry.marca + ' ' + entry.modelo); return true; } async function cancelarVentaExterna(saleId, saltarConfirmacion) { const list = await getSalesWithIds('ventas_externas'); const entry = list.find(e => e && e.saleId === saleId); if (!entry) return false; if (entry.estado === 'cancelada') { if (!saltarConfirmacion) alertBonito('Esta venta ya estaba cancelada.'); return false; } const resumen = entry.nombre + ' — ' + money(entry.precio) + ' (' + entry.vendedor + ')'; if (!saltarConfirmacion && !(await confirmarBonito(resumen, '¿Cancelar esta venta?'))) return false; entry.estado = 'cancelada'; entry.canceladaEn = new Date().toISOString(); await setListToStorage('ventas_externas', list); await reversarCajaDeVenta(entry, entry.nombre); return true; } // --- Caja (cash register) --- async function getCajaEstado() { if (hasSupabase) { try { const v = await supaGet('caja_estado'); if (v) return v; } catch (e) {} } if (hasSharedStorage) { try { const r = await window.storage.get('caja_estado', true); return r ? JSON.parse(r.value) : { abierta: false }; } catch (e) {} } try { return JSON.parse(localStorage.getItem('caja_estado') || '{"abierta":false}'); } catch (e) { return { abierta: false }; } } async function setCajaEstado(estado) { if (hasSupabase) { try { await supaSet('caja_estado', estado); scheduleAutoBackup(); return; } catch (e) {} } if (hasSharedStorage) { try { await window.storage.set('caja_estado', JSON.stringify(estado), true); scheduleAutoBackup(); return; } catch (e) {} } try { localStorage.setItem('caja_estado', JSON.stringify(estado)); } catch (e) { avisarFalloGuardado(); } scheduleAutoBackup(); } async function getCajaMovimientos() { return await getListFromStorage('caja_movimientos'); } async function addCajaMovimiento(tipo, concepto, monto) { const list = await getCajaMovimientos(); list.push({ tipo, concepto, monto, fecha: new Date().toISOString() }); await setListToStorage('caja_movimientos', list); } function cajaBalance(fondoInicial, movimientos) { let saldo = fondoInicial; movimientos.forEach(m => { if (m) saldo += (m.tipo === 'entrada' ? (Number(m.monto) || 0) : -(Number(m.monto) || 0)); }); return saldo; } async function registrarVentaEnCaja(articulo, pago) { const estado = await getCajaEstado(); if (!estado.abierta || pago.metodo !== 'Efectivo') return; await addCajaMovimiento('entrada', 'Venta: ' + articulo + ' (recibido)', pago.recibido); if (pago.cambio > 0) { await addCajaMovimiento('salida', 'Cambio entregado: ' + articulo, pago.cambio); } } function generateCustomTicket(nombre, caracteristicas, precioVenta, comprador, vendedor, pago) { const now = new Date(); const fecha = now.toLocaleDateString('es-MX') + ' ' + now.toLocaleTimeString('es-MX'); const folio = 'Folio: EXT-' + now.getTime().toString().slice(-8); const safeName = nombre.replace(/[^a-z0-9]+/gi, '_').slice(0, 30); const filenameBase = 'Ticket_' + safeName + '_' + now.toISOString().slice(0,10); setLastReceipt({ folio, fecha, articulo: nombre, lineas: caracteristicas ? ['Características: ' + caracteristicas] : [], precio: precioVenta, pago, comprador, vendedor, }); if (typeof window.jspdf === 'undefined') { downloadTextFallback(filenameBase + '.txt', buildCustomTicketText(nombre, caracteristicas, precioVenta, comprador, vendedor, folio, fecha, pago)); return; } try { const { jsPDF } = window.jspdf; const doc = new jsPDF({ unit: 'mm', format: getTipoImpresora() === 'normal' ? 'letter' : [80, 195] }); let y = 8; try { const logoW = 40, logoH = logoW * 157 / 400; doc.addImage('data:image/png;base64,' + LOGO_B64, 'PNG', 40 - logoW/2, y, logoW, logoH); y += logoH + 4; } catch (e) {} doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.text(STORE_NAME, 40, y, { align: 'center' }); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.text(STORE_ADDRESS, 40, y, { align: 'center', maxWidth: 68 }); y += 3.2; doc.text('Tel. ' + STORE_PHONE, 40, y, { align: 'center' }); y += 4; doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.text('TICKET DE VENTA', 40, y, { align: 'center' }); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.text(folio, 40, y, { align: 'center' }); y += 4; doc.text('Fecha y hora: ' + fecha, 40, y, { align: 'center' }); y += 6; doc.setLineWidth(0.2); doc.line(6, y, 74, y); y += 5; doc.setFont('helvetica', 'bold'); doc.setFontSize(10); const nameLines = doc.splitTextToSize(nombre, 68); nameLines.forEach(l => { doc.text(l, 6, y); y += 4.5; }); if (caracteristicas) { doc.setFont('helvetica', 'normal'); doc.setFontSize(8); const caracLines = doc.splitTextToSize('Características: ' + caracteristicas, 68); caracLines.forEach(l => { doc.text(l, 6, y); y += 4.2; }); } y += 2; doc.line(6, y, 74, y); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.text('Precio: ' + money(precioVenta), 40, y, { align: 'center' }); y += 7; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.text('Método de pago: ' + pago.metodo, 6, y); y += 4.5; if (pago.metodo === 'Efectivo') { doc.text('Recibido: ' + money(pago.recibido), 6, y); y += 4.5; doc.text('Cambio: ' + money(pago.cambio), 6, y); y += 4.5; } if (comprador) { doc.text('Comprador: ' + comprador, 6, y); y += 5; } doc.text('Vendedor: ' + vendedor, 6, y); y += 5; y = agregarFirmaAlPdf(doc, y, pago.firma); y += 2; doc.line(6, y, 74, y); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(8); doc.text('Garantía de 30 días', 40, y, { align: 'center' }); y += 4.5; doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.text('No hay reembolso de dinero,', 40, y, { align: 'center' }); y += 4; doc.text('solo cambio de mercancía.', 40, y, { align: 'center' }); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.text('¿Necesitas factura?', 40, y, { align: 'center' }); y += 3.8; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.text('WhatsApp 000-000-0000 con foto del', 40, y, { align: 'center' }); y += 3.5; doc.text('ticket y tu constancia de situación', 40, y, { align: 'center' }); y += 3.5; doc.text('fiscal (o tus datos fiscales).', 40, y, { align: 'center' }); y += 6; doc.setFontSize(7); doc.setTextColor(120); doc.text('Gracias por su compra', 40, y, { align: 'center' }); mostrarPreviaImpresion(doc, 'Vista previa del recibo', filenameBase + '.pdf'); } catch (e) { downloadTextFallback(filenameBase + '.txt', buildCustomTicketText(nombre, caracteristicas, precioVenta, comprador, vendedor, folio, fecha, pago)); } } function abrirVentaExternaModal() { document.getElementById('veNombreInput').value = ''; document.getElementById('veCaracteristicasInput').value = ''; document.getElementById('vePrecioInput').value = ''; document.getElementById('veCompradorInput').value = ''; document.getElementById('ventaExternaModal').style.display = 'flex'; document.getElementById('veNombreInput').focus(); } document.getElementById('veCancelBtn').addEventListener('click', () => { document.getElementById('ventaExternaModal').style.display = 'none'; }); document.getElementById('veContinuarBtn').addEventListener('click', async () => { const nombre = document.getElementById('veNombreInput').value.trim(); if (!nombre) { alertBonito('Escribe el nombre del artículo.'); return; } const caracteristicas = document.getElementById('veCaracteristicasInput').value.trim(); const precioVenta = parseFloat((document.getElementById('vePrecioInput').value || '').replace(/[^0-9.]/g, '')) || 0; if (precioVenta <= 0) { alertBonito('Escribe un precio de venta válido.'); return; } const compradorPrevio = document.getElementById('veCompradorInput').value.trim(); document.getElementById('ventaExternaModal').style.display = 'none'; const detalles = await pedirDetallesVenta(precioVenta); if (!detalles) return; const comprador = compradorPrevio || detalles.comprador; const vendedor = currentSession ? currentSession.nombre : 'No especificado'; const saleIdExterna = await logCustomSale({ nombre, caracteristicas, precio: precioVenta, comprador, vendedor, fecha: new Date().toISOString(), metodoPago: detalles.metodo, recibido: detalles.recibido, cambio: detalles.cambio, }, detalles.facturaSolicitada); registrarUltimaAccion({ tipo: 'venta_externa', saleIds: [saleIdExterna], descripcion: 'venta externa de ' + nombre }); await registrarVentaEnCaja(nombre, detalles); generateCustomTicket(nombre, caracteristicas, precioVenta, comprador, vendedor, detalles); reproducirSonido('venta'); }); document.getElementById('agregarCarritoBtn').addEventListener('click', () => { if (!currentItem) return; agregarAlCarrito(currentItem); }); document.getElementById('editarDesdeCardBtn').addEventListener('click', () => { if (!currentItem) return; abrirMercanciaModal(currentItem); }); document.getElementById('soldBtn').addEventListener('click', async () => { if (!currentItem) return; const btn = document.getElementById('soldBtn'); if (btn.disabled) return; // ya se está procesando este mismo clic, evita doble venta por doble toque btn.disabled = true; try { const alreadySold = soldSet.has(currentItem.id); if (!alreadySold) { const precioStr = await pedirTexto('Precio final de venta (MXN)', currentItem.precioBueno, { numerico: true }); if (precioStr === null) return; // cancelled const precioVenta = parseFloat(precioStr.replace(/[^0-9.]/g, '')) || currentItem.precioBueno; const detalles = await pedirDetallesVenta(precioVenta); if (!detalles) return; // cancelled const { comprador, facturaSolicitada, ...pago } = detalles; const vendedor = currentSession ? currentSession.nombre : 'No especificado'; soldSet.add(currentItem.id); await saveSoldSet(); const saleIdVendida = await logInventorySale({ id: currentItem.id, marca: currentItem.marca, modelo: currentItem.modelo, precio: precioVenta, comprador, vendedor, fecha: new Date().toISOString(), metodoPago: pago.metodo, recibido: pago.recibido, cambio: pago.cambio, }, facturaSolicitada); registrarUltimaAccion({ tipo: 'venta', saleIds: [saleIdVendida], descripcion: 'venta de ' + currentItem.marca + ' ' + currentItem.modelo }); await registrarVentaEnCaja(currentItem.marca + ' ' + currentItem.modelo, pago); generateTicket(currentItem, precioVenta, comprador, vendedor, pago); reproducirSonido('venta'); } else { soldSet.delete(currentItem.id); await saveSoldSet(); const removedEntry = await removeInventorySaleLog(currentItem.id); if (removedEntry) { await reversarCajaDeVenta(removedEntry, removedEntry.marca + ' ' + removedEntry.modelo); } } updateSoldUI(currentItem); updateLoadedCount(); if (listView.classList.contains('show')) { updateStats(); renderList(listSearch.value); } } finally { btn.disabled = false; } }); function addHistory(item) { history.unshift(item); if (history.length > 6) history.pop(); historyWrap.style.display = 'block'; historyList.innerHTML = ''; history.forEach(h => { const row = document.createElement('div'); row.className = 'hist-row'; row.innerHTML = `${escapeHtmlCell(h.id)}${escapeHtmlCell(h.marca)} ${escapeHtmlCell(h.modelo)}`; row.onclick = () => { render(h); }; historyList.appendChild(row); }); } // --- Sonido al escanear (beep de exito / beep de no encontrado) --- let audioCtxGlobal = null; function getAudioCtx() { if (!audioCtxGlobal) { try { audioCtxGlobal = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } } if (audioCtxGlobal.state === 'suspended') { audioCtxGlobal.resume().catch(() => {}); } return audioCtxGlobal; } function reproducirTono(frecuencia, duracionMs, tipo) { const ctx = getAudioCtx(); if (!ctx) return; try { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = tipo || 'sine'; osc.frequency.value = frecuencia; gain.gain.value = 0.16; osc.connect(gain); gain.connect(ctx.destination); const ahora = ctx.currentTime; osc.start(ahora); gain.gain.exponentialRampToValueAtTime(0.001, ahora + duracionMs / 1000); osc.stop(ahora + duracionMs / 1000 + 0.02); } catch (e) {} } function beepEncontrado() { reproducirTono(1300, 90, 'sine'); } function beepNoEncontrado() { reproducirTono(260, 140, 'square'); setTimeout(() => reproducirTono(200, 160, 'square'), 130); } // Algunos lectores de codigo de barras mandan un caracter distinto para el guion // (por ejemplo apostrofe ' en vez de -) cuando el teclado de Windows esta en español. // Esto normaliza cualquier separador raro a un guion, para que el ID siga funcionando. function normalizarCodigoEscaneado(code) { return code.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } // Calcula que tan "parecidos" son dos textos (numero minimo de cambios para pasar de uno a otro), // para poder sugerir el ID mas cercano cuando alguien escanea o escribe mal. function distanciaTexto(a, b) { const m = a.length, n = b.length; if (m === 0) return n; if (n === 0) return m; const fila = new Array(n + 1); for (let j = 0; j <= n; j++) fila[j] = j; for (let i = 1; i <= m; i++) { let anterior = fila[0]; fila[0] = i; for (let j = 1; j <= n; j++) { const temp = fila[j]; fila[j] = a[i - 1] === b[j - 1] ? anterior : 1 + Math.min(anterior, fila[j], fila[j - 1]); anterior = temp; } } return fila[n]; } function buscarSugerenciasId(code) { if (!code) return []; const maxDistancia = Math.max(2, Math.ceil(code.length * 0.45)); return DATA .filter(d => !soldSet.has(d.id)) .map(d => ({ item: d, dist: distanciaTexto(code, (d.id || '').toUpperCase()) })) .filter(x => x.dist <= maxDistancia) .sort((a, b) => a.dist - b.dist) .slice(0, 5) .map(x => x.item); } // Para cuando escriben solo el principio o el final del ID (ej. solo los ultimos 4 // digitos), sin guiones necesariamente, en vez del ID completo. function limpiarParaFragmento(s) { return (s || '').toUpperCase().replace(/-/g, ''); } function buscarPorFragmentoId(code) { const codeLimpio = limpiarParaFragmento(code); if (codeLimpio.length < 3) return []; return DATA.filter(d => { const idLimpio = limpiarParaFragmento(d.id); return idLimpio.length > codeLimpio.length && (idLimpio.endsWith(codeLimpio) || idLimpio.startsWith(codeLimpio)); }); } // Para cuando en vez de un ID escriben el nombre/modelo del producto (ej. "hp elitebook") function buscarPorNombreModelo(texto) { if (!texto || texto.trim().length < 3) return []; const palabras = texto.toUpperCase().trim().split(/[\s-]+/).filter(Boolean); if (palabras.length === 0) return []; return DATA.filter(d => { const nombre = (d.marca + ' ' + d.modelo + ' ' + (d.procesador || '')).toUpperCase(); return palabras.every(p => nombre.includes(p)); }).slice(0, 8); } function renderizarListaSugerencias(items, titulo) { const cont = document.getElementById('notFoundSugerencias'); if (!cont) return; if (!items || items.length === 0) { cont.innerHTML = ''; return; } cont.innerHTML = '
' + titulo + '
' + items.map(it => ( '' )).join(''); cont.querySelectorAll('.nf-sugerencia-chip').forEach(btn => { btn.addEventListener('click', () => lookup(btn.dataset.id)); }); } function mostrarSugerenciasNoEncontrado(code) { renderizarListaSugerencias(buscarSugerenciasId(code), '¿Buscabas alguno de estos?'); } function lookup(raw) { const codeOriginal = raw.trim().toUpperCase(); if (!codeOriginal) return; const code = INDEX[codeOriginal] ? codeOriginal : normalizarCodigoEscaneado(codeOriginal); let item = INDEX[code]; // si no hubo coincidencia exacta, prueba si escribieron solo el inicio o el final del ID if (!item) { const coincidencias = buscarPorFragmentoId(code); if (coincidencias.length === 1) { item = coincidencias[0]; } else if (coincidencias.length > 1) { card.classList.remove('show'); notFound.classList.add('show'); renderizarListaSugerencias(coincidencias.slice(0, 8), 'Varios productos coinciden con "' + codeOriginal + '" — ¿cuál es?'); beepNoEncontrado(); input.value = ''; return; } } if (item) { render(item); addHistory(item); beepEncontrado(); if (conteoActivo && !soldSet.has(item.id)) { conteoEscaneados.add(item.id); actualizarBannerConteo(); agregarEscaneoConteoEnLinea(item.id); } } else { // si tampoco hubo coincidencia por ID, intenta buscar por nombre/modelo escrito const porNombre = buscarPorNombreModelo(codeOriginal); card.classList.remove('show'); notFound.classList.add('show'); if (porNombre.length > 0) { renderizarListaSugerencias(porNombre, 'Productos que coinciden con "' + codeOriginal + '":'); } else { mostrarSugerenciasNoEncontrado(code); } beepNoEncontrado(); } input.value = ''; } input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { lookup(input.value); } }); function anyModalOpen() { return Array.from(document.querySelectorAll('.modal-overlay')).some(m => m.style.display === 'flex'); } // --- Foquito de estado + sincronizacion silenciosa en segundo plano --- function marcarEstado(estado) { // estado: 'ok' | 'warn' | 'error' const dot = document.getElementById('statusDot'); if (!dot) return; dot.classList.remove('dot-warn', 'dot-error'); if (estado === 'warn') dot.classList.add('dot-warn'); if (estado === 'error') dot.classList.add('dot-error'); } // Trae los cambios que hayan hecho otros dispositivos (vendidos, agotados, mercancia nueva, // ediciones, eliminaciones) y los mezcla sin tocar nada de lo que esta viendo el usuario en // este momento (no reabre modales, no interrumpe si esta escribiendo algo). async function sincronizacionSilenciosa(forzado) { if (!forzado && anyModalOpen()) return; // no se mete si hay un formulario abierto, para no perder lo que estan escribiendo try { const soldSizeAntes = soldSet.size, agotadoSizeAntes = agotadoSet.size, dataLenAntes = DATA.length; const [nuevosVendidos, nuevosAgotados, nuevoPublicadoInfo] = await Promise.all([ getListFromStorage('vendidos'), getListFromStorage('agotados'), getPublicadoInfo(), ]); new Set(nuevosVendidos).forEach(id => soldSet.add(id)); new Set(nuevosAgotados).forEach(id => agotadoSet.add(id)); publicadoInfo = nuevoPublicadoInfo; if (currentItem) updatePublicadoUI(currentItem); await mergeExtraInventory(); await aplicarEliminadosDelInventario(); await aplicarCorreccionesDelInventario(); // revisa si hay mensajes nuevos en el chat grupal que yo todavia no haya visto — // si aumentaron desde la ultima revision (y no soy yo quien los escribio), suena // un aviso y se prende el numerito en el boton del chat try { const mensajesChat = await getValorCompartido(CHAT_GRUPAL_KEY, []); const miNombre = currentSession ? currentSession.nombre : null; if (Array.isArray(mensajesChat) && miNombre) { const noLeidos = mensajesChat.filter(m => m.quien !== miNombre && (!Array.isArray(m.vistoPor) || !m.vistoPor.includes(miNombre))).length; const chatAbierto = document.getElementById('chatGrupalModal').style.display === 'flex'; const badge = document.getElementById('chatGrupalBadge'); if (badge) { if (noLeidos > 0 && !chatAbierto) { badge.textContent = noLeidos > 9 ? '9+' : String(noLeidos); badge.style.display = 'flex'; } else { badge.style.display = 'none'; } } if (chatGrupalNoLeidosAnterior !== null && noLeidos > chatGrupalNoLeidosAnterior && !chatAbierto) { reproducirSonido('chat'); } chatGrupalNoLeidosAnterior = noLeidos; } } catch (e) {} // si hay un conteo de inventario activo, trae lo que otros dispositivos hayan escaneado if (conteoActivo) { try { const remoto = await getConteoRemoto(); if (remoto) { if (remoto.activo === false) { // alguien mas ya cerro este conteo desde otro dispositivo conteoActivo = false; } else if (Array.isArray(remoto.escaneados)) { remoto.escaneados.forEach(id => conteoEscaneados.add(id)); } actualizarBannerConteo(); } } catch (e) {} } updateLoadedCount(); // solo se vuelve a dibujar la lista completa si de verdad cambio algo — asi no se // repite ese trabajo cada 10 segundos sin necesidad mientras alguien la esta viendo const huboCambios = soldSet.size !== soldSizeAntes || agotadoSet.size !== agotadoSizeAntes || DATA.length !== dataLenAntes; if (huboCambios && listView.classList.contains('show')) renderList(listSearch.value); marcarEstado('ok'); } catch (e) { marcarEstado('warn'); throw e; } } // Avisa (sin ser intrusivo) si el respaldo automatico diario no se ha logrado en varios dias, // por si hay algun problema de conexion que nadie ha notado. async function verificarSaludRespaldo() { const box = document.getElementById('alertaRespaldoBox'); if (!box || !hasSupabase) return; try { const ultimo = await supaGet('ultimo_respaldo_auto'); if (!ultimo) { box.style.display = 'none'; return; } const dias = Math.floor((new Date() - new Date(ultimo)) / 86400000); if (dias >= 2) { box.innerHTML = ' El respaldo automático a la nube no se ha completado en ' + dias + ' días. Revisa tu conexión a internet.'; box.style.display = 'flex'; marcarEstado('warn'); } else { box.style.display = 'none'; } } catch (e) { /* no molestar si esto en si falla */ } } document.addEventListener('click', () => { if (anyModalOpen()) return; if (!camWrapEl.classList.contains('show') && !listView.classList.contains('show') && !sellerView.classList.contains('show') && !misVentasView.classList.contains('show') && !facturasView.classList.contains('show') && !carritoView.classList.contains('show') && !garantiasView.classList.contains('show') && !cotizacionesView.classList.contains('show') && !proveedoresView.classList.contains('show') && !clientesView.classList.contains('show') && !auditoriaView.classList.contains('show') && !cajaView.classList.contains('show') && !reportView.classList.contains('show') && !backupView.classList.contains('show')) input.focus(); }); window.addEventListener('load', async () => { try { input.focus(); } catch (e) {} try { document.getElementById('loadedCount').textContent = 'cargando...'; } catch (e) {} // vigilante: si en 6 segundos el contador sigue diciendo "cargando...", lo fuerza a // mostrar algo de todas formas, para que la pantalla nunca se quede pegada const vigilante = setTimeout(() => { const el = document.getElementById('loadedCount'); if (el && el.textContent === 'cargando...') { try { updateLoadedCount(); } catch (e) { el.textContent = '0 disponibles de ' + (Array.isArray(DATA) ? DATA.length : '?') + ' · hubo un problema al sincronizar, intenta recargar'; } } }, 6000); // Los 3 primeros pasos tienen que ir en orden (cada uno depende de que el anterior ya // haya terminado de modificar el inventario en memoria). Los otros 3 son independientes // entre si y de esos 3 primeros, asi que corren todos en paralelo — esto hace que la // app cargue bastante mas rapido al entrar, en vez de esperar una cosa tras otra. await Promise.all([ (async () => { try { await mergeExtraInventory(); } catch (e) {} try { await aplicarEliminadosDelInventario(); } catch (e) {} try { await aplicarCorreccionesDelInventario(); } catch (e) {} })(), (async () => { try { await loadSoldSet(); } catch (e) {} })(), (async () => { try { await loadAgotadoSet(); } catch (e) {} })(), (async () => { try { await loadPublicadoInfo(); } catch (e) {} })(), ]); updateLoadedCount(); clearTimeout(vigilante); actualizarBotonDeshacer(); try { await checkAndResetPeriodoVentas(); } catch (e) {} try { await tryRestoreBackupHandlePermission(); } catch (e) {} try { respaldoAutomaticoDiarioANube(); } catch (e) {} // en segundo plano, no bloquea el arranque try { verificarSaludRespaldo(); } catch (e) {} }); setInterval(checkAndResetPeriodoVentas, 60 * 60 * 1000); // revisa también cada hora si la página se queda abierta varios días setInterval(verificarRecordatorioCaja, 15 * 60 * 1000); // revisa cada 15 minutos si ya es tarde y la caja sigue abierta setInterval(() => { sincronizacionSilenciosa().catch(() => {}); }, 10 * 1000); // trae cambios de otros dispositivos cada 10 segundos, sin interrumpir nada // en cuanto la persona vuelve a esta pestaña/app (después de tenerla en segundo plano, // cambiar de app en el celular, o encender la pantalla), se sincroniza al instante document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { sincronizacionSilenciosa().catch(() => {}); } }); window.addEventListener('focus', () => { sincronizacionSilenciosa().catch(() => {}); }); setInterval(verificarSaludRespaldo, 60 * 60 * 1000); // revisa cada hora si el respaldo diario se ha estado logrando // --- camera scanning (for phones without a hardware barcode reader) --- const camBtn = document.getElementById('camBtn'); const camStopBtn = document.getElementById('camStopBtn'); const camWrapEl = document.getElementById('camWrap'); const camMsg = document.getElementById('camMsg'); let html5QrCode = null; let camRunning = false; let camFacingMode = 'environment'; function stopCamera() { camWrapEl.classList.remove('show'); if (html5QrCode && camRunning) { html5QrCode.stop().then(() => html5QrCode.clear()).catch(() => {}); camRunning = false; } input.focus(); } async function iniciarCamara() { camMsg.textContent = 'Apunta la cámara al código de barras de la etiqueta…'; try { html5QrCode = new Html5Qrcode('camReader'); await html5QrCode.start( { facingMode: camFacingMode }, { fps: 12, qrbox: { width: 260, height: 140 }, formatsToSupport: [Html5QrcodeSupportedFormats.CODE_128] }, (decodedText) => { camMsg.textContent = 'Código detectado: ' + decodedText; lookup(decodedText); stopCamera(); }, () => { /* ignore per-frame no-detection noise */ } ); camRunning = true; } catch (err) { camMsg.textContent = 'No se pudo abrir la cámara. Revisa los permisos de cámara del navegador, o usa el modo de escritura manual arriba.'; } } camBtn.addEventListener('click', async () => { if (typeof Html5Qrcode === 'undefined') { camMsg.textContent = 'No se pudo cargar el lector de cámara (revisa tu conexión a internet e intenta de nuevo).'; camWrapEl.classList.add('show'); return; } camWrapEl.classList.add('show'); await iniciarCamara(); }); document.getElementById('camFlipBtn').addEventListener('click', async () => { camFacingMode = camFacingMode === 'environment' ? 'user' : 'environment'; if (html5QrCode && camRunning) { try { await html5QrCode.stop(); await html5QrCode.clear(); } catch (e) {} camRunning = false; } await iniciarCamara(); }); camStopBtn.addEventListener('click', stopCamera); // --- full inventory list view --- const scanView = document.getElementById('scanView'); const listView = document.getElementById('listView'); const sellerView = document.getElementById('sellerView'); const misVentasView = document.getElementById('misVentasView'); const facturasView = document.getElementById('facturasView'); const carritoView = document.getElementById('carritoView'); const garantiasView = document.getElementById('garantiasView'); const cotizacionesView = document.getElementById('cotizacionesView'); const proveedoresView = document.getElementById('proveedoresView'); const clientesView = document.getElementById('clientesView'); const auditoriaView = document.getElementById('auditoriaView'); const cajaView = document.getElementById('cajaView'); const reportView = document.getElementById('reportView'); const weeklyReportBtn = document.getElementById('weeklyReportBtn'); const backupView = document.getElementById('backupView'); const backupBtn = document.getElementById('backupBtn'); const cotizacionBtn = document.getElementById('cotizacionBtn'); const viewListBtn = document.getElementById('viewListBtn'); const sellerStatsBtn = document.getElementById('sellerStatsBtn'); const cajaBtn = document.getElementById('cajaBtn'); const homeBtn = document.getElementById('homeBtn'); const homeSection = document.getElementById('homeSection'); const listSearch = document.getElementById('listSearch'); const listTable = document.getElementById('listTable'); const filterMarca = document.getElementById('filterMarca'); const filterCategoria = document.getElementById('filterCategoria'); const filterSoloNuevos = document.getElementById('filterSoloNuevos'); const filterPrecioMin = document.getElementById('filterPrecioMin'); const filterPrecioMax = document.getElementById('filterPrecioMax'); const filterSoloTouch = document.getElementById('filterSoloTouch'); const filterSoloGpu = document.getElementById('filterSoloGpu'); const filterAgrupar = document.getElementById('filterAgrupar'); const filterProc = document.getElementById('filterProc'); const filterRam = document.getElementById('filterRam'); const filterAlm = document.getElementById('filterAlm'); const filterTouch = document.getElementById('filterTouch'); const filterGpu = document.getElementById('filterGpu'); const filterCount = document.getElementById('filterCount'); function ramStorageKey(str) { const m = (str || '').match(/(\d+(?:\.\d+)?)\s*(GB|TB)?/i); if (!m) return Number.MAX_SAFE_INTEGER; let num = parseFloat(m[1]); if ((m[2] || '').toUpperCase() === 'TB') num *= 1024; return num; } function procKey(proc) { const low = (proc || '').toLowerCase(); if (!low) return [9, 0, 0]; const famRank = { '3': 0, '5': 1, '7': 2, '9': 3 }; let m = low.match(/intel core i(\d) de (\d+)\.ª generaci/); if (m) return [0, famRank[m[1]] ?? 9, parseInt(m[2])]; m = low.match(/intel core i(\d)$/); if (m) return [0, famRank[m[1]] ?? 9, 50]; m = low.match(/intel core m(\d) de (\d+)\.ª generaci/); if (m) return [0, 4, parseInt(m[2])]; if (low.startsWith('intel celeron')) { m = low.match(/de (\d+)\.ª generaci/); return [1, 0, m ? parseInt(m[1]) : 0]; } if (low.startsWith('intel pentium')) return [2, 0, 0]; if (low.startsWith('intel xeon')) return [3, 0, 0]; m = low.match(/amd ryzen (\d)/); if (m) return [4, famRank[m[1]] ?? 9, 0]; if (low.startsWith('amd athlon')) return [5, 0, 0]; if (low.match(/^amd a\d/) || low.includes('amd fx') || low.includes('amd e1')) return [6, 0, 0]; return [7, 0, 0]; } function compareArrays(a, b) { for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = a[i] || 0, y = b[i] || 0; if (x !== y) return x - y; } return 0; } function sortValues(values, field) { if (field === 'ram' || field === 'alm') { return values.slice().sort((a, b) => ramStorageKey(a) - ramStorageKey(b)); } if (field === 'proc') { return values.slice().sort((a, b) => compareArrays(procKey(a), procKey(b))); } return values.slice().sort((a, b) => a.localeCompare(b, 'es')); } function populateFilters() { updateFilterOptions(); } // Ordena IDs "de forma natural": A-2 antes que A-10 (no como texto plano, donde "10" // sale antes que "2"). Se usa para que el inventario y las etiquetas salgan en orden, // no en el orden en que se fueron guardando por dentro. function compararIds(a, b) { return String(a || '').localeCompare(String(b || ''), 'es', { numeric: true, sensitivity: 'base' }); } function matchesFilters(d, { skip, text } = {}) { const q = (text || '').trim().toLowerCase(); if (q && !(d.id.toLowerCase().includes(q) || d.marca.toLowerCase().includes(q) || d.modelo.toLowerCase().includes(q) || (d.numeroSerie || '').toLowerCase().includes(q))) return false; if (skip !== 'categoria' && filterCategoria.value && (d.categoria || 'Laptops') !== filterCategoria.value) return false; if (skip !== 'marca' && filterMarca.value && d.marca !== filterMarca.value) return false; if (skip !== 'proc' && filterProc.value && d.procesador !== filterProc.value) return false; if (skip !== 'ram' && filterRam.value && d.ram !== filterRam.value) return false; if (skip !== 'alm' && filterAlm.value && d.almacenamiento !== filterAlm.value) return false; if (skip !== 'touch' && filterTouch.value && (d.touch || '') !== filterTouch.value) return false; if (skip !== 'gpu' && filterGpu.value && (d.gpu || '') !== filterGpu.value) return false; if (filterSoloNuevos.checked && !d.fechaAgregado) return false; if (filterSoloTouch.checked && !d.touch) return false; if (filterSoloGpu.checked && !d.gpu) return false; const precioMin = parseFloat(filterPrecioMin.value); const precioMax = parseFloat(filterPrecioMax.value); if (!isNaN(precioMin) && d.precioBueno < precioMin) return false; if (!isNaN(precioMax) && d.precioBueno > precioMax) return false; return true; } const CATEGORIAS_INVENTARIO = ['Monitores', 'Laptops', 'PC Gamer', 'CPUs', 'Cargadores', 'Teclados', 'Mouses', 'Bocinas', 'Proyectores', 'Otros']; function rebuildSelectFijo(sel, values, placeholder) { const prev = sel.value; sel.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = placeholder; sel.appendChild(opt0); values.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; sel.appendChild(opt); }); sel.value = values.includes(prev) ? prev : ''; } function rebuildSelect(sel, values, placeholder) { const prev = sel.value; sel.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = placeholder; sel.appendChild(opt0); values.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; sel.appendChild(opt); }); sel.value = values.includes(prev) ? prev : ''; } function updateFilterOptions() { const uniq = (arr) => [...new Set(arr.filter(Boolean))]; const text = listSearch.value; rebuildSelectFijo(filterCategoria, CATEGORIAS_INVENTARIO, 'Categoría (todas)'); rebuildSelect(filterMarca, sortValues(uniq(DATA.filter(d => matchesFilters(d, { skip: 'marca', text })).map(d => d.marca)), 'marca'), 'Marca (todas)'); rebuildSelect(filterProc, sortValues(uniq(DATA.filter(d => matchesFilters(d, { skip: 'proc', text })).map(d => d.procesador)), 'proc'), 'Procesador (todos)'); rebuildSelect(filterRam, sortValues(uniq(DATA.filter(d => matchesFilters(d, { skip: 'ram', text })).map(d => d.ram)), 'ram'), 'RAM (todas)'); rebuildSelect(filterAlm, sortValues(uniq(DATA.filter(d => matchesFilters(d, { skip: 'alm', text })).map(d => d.almacenamiento)), 'alm'), 'Almacenamiento (todos)'); rebuildSelect(filterTouch, sortValues(uniq(DATA.filter(d => matchesFilters(d, { skip: 'touch', text })).map(d => d.touch)), 'touch'), 'Touch/2en1/360 (todos)'); rebuildSelect(filterGpu, sortValues(uniq(DATA.filter(d => matchesFilters(d, { skip: 'gpu', text })).map(d => d.gpu)), 'gpu'), 'Gráfica dedicada (todas)'); } populateFilters(); let modoSeleccion = false; let seleccionados = new Set(); function actualizarBarraSeleccion() { const bar = document.getElementById('seleccionBar'); const count = document.getElementById('seleccionCount'); if (modoSeleccion && seleccionados.size > 0) { bar.style.display = 'flex'; count.textContent = seleccionados.size + ' seleccionado' + (seleccionados.size === 1 ? '' : 's'); } else { bar.style.display = 'none'; } } function firmaProducto(d) { return [d.categoria || 'Laptops', d.marca, d.modelo, d.procesador, d.ram, d.almacenamiento, d.touch, d.gpu, d.precioBueno, d.precioCosmetico].join('|'); } function renderListAgrupado(filtered) { const grupos = new Map(); filtered.forEach(d => { const key = firmaProducto(d); if (!grupos.has(key)) grupos.set(key, []); grupos.get(key).push(d); }); const gruposOrdenados = [...grupos.entries()].sort((a, b) => { const nombreA = (a[1][0].marca + ' ' + a[1][0].modelo).toLowerCase(); const nombreB = (b[1][0].marca + ' ' + b[1][0].modelo).toLowerCase(); return nombreA.localeCompare(nombreB, 'es'); }); gruposOrdenados.forEach(([key, items]) => { items.sort((a, b) => compararIds(a.id, b.id)); const disponibles = items.filter(d => !soldSet.has(d.id)); const vendidas = items.length - disponibles.length; const primero = items[0]; const publicadas = items.filter(d => quienesPublicaron(d.id).length > 0).length; const confirmadoresUnion = new Set(); items.forEach(d => quienesPublicaron(d.id).forEach(q => confirmadoresUnion.add(q))); let focoClase, focoIcono, focoTexto; if (publicadas === items.length) { focoClase = 'encendido'; focoIcono = 'ti-bulb'; focoTexto = 'Todas publicadas (por: ' + [...confirmadoresUnion].join(', ') + ')'; } else if (publicadas > 0) { focoClase = 'parcial'; focoIcono = 'ti-bulb'; focoTexto = publicadas + ' de ' + items.length + ' publicadas (por: ' + [...confirmadoresUnion].join(', ') + ')'; } else { focoClase = 'apagado'; focoIcono = 'ti-bulb-off'; focoTexto = 'Pendiente de publicar'; } const row = document.createElement('div'); row.className = 'list-item'; row.innerHTML = `
${items.length} pieza${items.length === 1 ? '' : 's'} ${primero.marca} ${primero.modelo} ${disponibles.length} disponible${disponibles.length === 1 ? '' : 's'}${vendidas > 0 ? ' · ' + vendidas + ' vendida' + (vendidas === 1 ? '' : 's') : ''}
${money(primero.precioBueno)} ${focoClase === 'apagado' ? 'Pendiente' : (focoClase === 'encendido' ? 'Publicadas' : publicadas + '/' + items.length)}
`; row.onclick = () => showGroupDetailModal(items); listTable.appendChild(row); }); } function estadoFocoItem(id) { const confirmaron = quienesPublicaron(id); if (confirmaron.length > 0) { return { clase: 'encendido', icono: 'ti-bulb', tooltip: 'Publicado por: ' + confirmaron.join(', ') }; } return { clase: 'apagado', icono: 'ti-bulb-off', tooltip: 'Pendiente de publicar' }; } function showGroupDetailModal(items) { const primero = items[0]; document.getElementById('groupDetailTitle').textContent = (primero.marca + ' ' + primero.modelo).trim() + ' — ' + items.length + ' pieza' + (items.length === 1 ? '' : 's'); const cont = document.getElementById('groupDetailLista'); cont.innerHTML = ''; items.forEach(it => { const isSold = soldSet.has(it.id); const specs = construirSpecsEtiqueta(it).replace(/\n/g, ' · ') || (it.procesador || ''); const thumbHtml = it.fotoUrl ? `` : ''; const estado = estadoFocoItem(it.id); const row = document.createElement('div'); row.className = 'group-unit-row-full'; row.innerHTML = ` ${thumbHtml}
${it.id} ${isSold ? 'VENDIDA' : 'Disponible'}
${specs ? '
' + specs + '
' : ''}
${money(it.precioBueno)}
`; const checkbox = row.querySelector('.gu-checkbox'); checkbox.addEventListener('click', (e) => e.stopPropagation()); const focoMini = row.querySelector('.foco-publicado-mini'); focoMini.addEventListener('click', async (e) => { e.stopPropagation(); if (!currentSession) return; const nombre = currentSession.nombre; const lista = publicadoInfo[it.id] || []; const idx = lista.findIndex(r => r.quien === nombre); if (idx !== -1) lista.splice(idx, 1); else lista.push({ quien: nombre, fecha: new Date().toISOString() }); if (lista.length > 0) publicadoInfo[it.id] = lista; else delete publicadoInfo[it.id]; const nuevoEstado = estadoFocoItem(it.id); focoMini.className = 'foco-publicado-mini ' + nuevoEstado.clase; focoMini.title = nuevoEstado.tooltip; focoMini.querySelector('i').className = 'ti ' + nuevoEstado.icono; await savePublicadoInfo(); if (currentItem && currentItem.id === it.id) updatePublicadoUI(currentItem); if (listView.classList.contains('show')) renderList(listSearch.value); }); row.querySelector('.gu-info').onclick = () => { document.getElementById('groupDetailModal').style.display = 'none'; showProductActionsModal(it); }; cont.appendChild(row); }); document.getElementById('groupDetailModal').style.display = 'flex'; } document.getElementById('groupDetailAgregarCarritoBtn').addEventListener('click', () => { const checked = Array.from(document.querySelectorAll('#groupDetailLista .gu-checkbox:checked')); if (checked.length === 0) { alertBonito('Marca al menos una pieza para agregar al carrito.'); return; } let agregados = 0; checked.forEach(cb => { const item = INDEX[cb.dataset.id]; if (item && !soldSet.has(item.id) && !estaEnCarrito(item.id)) { carrito.push(item); agregados++; } }); actualizarBadgeCarrito(); document.getElementById('groupDetailModal').style.display = 'none'; alertBonito(agregados + ' pieza(s) agregada(s) al carrito.'); }); document.getElementById('groupDetailCerrarBtn').addEventListener('click', () => { document.getElementById('groupDetailModal').style.display = 'none'; }); function renderList(filterText) { const filtered = DATA.filter(d => matchesFilters(d, { text: filterText })).sort((a, b) => compararIds(a.id, b.id)); filterCount.textContent = filtered.length + ' de ' + DATA.length + ' equipos'; listTable.innerHTML = ''; if (filterAgrupar.checked) { renderListAgrupado(filtered); return; } filtered.forEach(d => { const isSold = soldSet.has(d.id); const row = document.createElement('div'); row.className = 'list-item' + (isSold ? ' is-sold' : ''); const fechaStr = d.fechaAgregado ? new Date(d.fechaAgregado).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' }) : ''; const checkboxHtml = modoSeleccion ? `` : ''; const thumbHtml = d.fotoUrl ? `` : ''; const estado = estadoFocoItem(d.id); row.innerHTML = ` ${checkboxHtml} ${thumbHtml}
${d.id} ${d.marca} ${d.modelo} ${fechaStr ? 'Agregado: ' + fechaStr + '' : ''}
${money(d.precioBueno)} ${isSold ? '' + (agotadoSet.has(d.id) ? 'Agotado' : 'Vendida') + '' : ''}
`; const focoMini = row.querySelector('.foco-publicado-mini'); focoMini.addEventListener('click', async (e) => { e.stopPropagation(); if (!currentSession) return; const nombre = currentSession.nombre; const lista = publicadoInfo[d.id] || []; const idx = lista.findIndex(r => r.quien === nombre); if (idx !== -1) lista.splice(idx, 1); else lista.push({ quien: nombre, fecha: new Date().toISOString() }); if (lista.length > 0) publicadoInfo[d.id] = lista; else delete publicadoInfo[d.id]; const nuevoEstado = estadoFocoItem(d.id); focoMini.className = 'foco-publicado-mini ' + nuevoEstado.clase; focoMini.title = nuevoEstado.tooltip; focoMini.querySelector('i').className = 'ti ' + nuevoEstado.icono; await savePublicadoInfo(); if (currentItem && currentItem.id === d.id) updatePublicadoUI(currentItem); }); if (modoSeleccion) { row.onclick = (e) => { if (isSold) return; const cb = row.querySelector('.li-checkbox'); if (e.target !== cb) cb.checked = !cb.checked; if (cb.checked) seleccionados.add(d.id); else seleccionados.delete(d.id); actualizarBarraSeleccion(); }; } else { row.onclick = () => { showProductActionsModal(d); }; } listTable.appendChild(row); }); } document.getElementById('modoSeleccionToggle').addEventListener('change', (e) => { modoSeleccion = e.target.checked; seleccionados.clear(); actualizarBarraSeleccion(); renderList(listSearch.value); }); document.getElementById('agregarSeleccionadosCarritoBtn').addEventListener('click', () => { if (seleccionados.size === 0) return; let agregados = 0; seleccionados.forEach(id => { const item = INDEX[id]; if (item && !soldSet.has(id) && !estaEnCarrito(id)) { carrito.push(item); agregados++; } }); actualizarBadgeCarrito(); seleccionados.clear(); modoSeleccion = false; document.getElementById('modoSeleccionToggle').checked = false; actualizarBarraSeleccion(); renderList(listSearch.value); alertBonito(agregados + ' producto(s) agregado(s) al carrito.'); }); function updateStats() { document.getElementById('statTotal').textContent = DATA.length; document.getElementById('statVend').textContent = soldSet.size; document.getElementById('statDisp').textContent = DATA.length - soldSet.size; } function showListView() { scanView.style.display = 'none'; listView.classList.add('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; updateStats(); renderList(listSearch.value); } function showScanView() { scanView.style.display = 'block'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'block'; homeBtn.style.display = 'none'; input.focus(); } async function showSellerView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.add('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderSellerStats(); } async function showMisVentasView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.add('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderMisVentas(); } async function showFacturasView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.add('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderFacturasPendientes(); } async function showCarritoView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.add('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; renderCarrito(); // enfoca el campo de ID automatico, asi si tienes una pistola escaner conectada // puede escanear directo sin tener que tocar el campo primero setTimeout(() => { try { document.getElementById('carritoIdInput').focus(); } catch (e) {} }, 50); } async function showGarantiasView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.add('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderGarantias(); } async function showCotizacionesView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.add('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderCotizaciones(); } document.getElementById('cotizacionesBtn').addEventListener('click', showCotizacionesView); async function showProveedoresView() { if (!currentSession || currentSession.rol !== 'admin') { alertBonito('No tienes acceso a esta sección.'); return; } scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.add('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; document.getElementById('agregarProveedorBtn').style.display = puedeEditarProveedores ? '' : 'none'; await renderProveedores(); } document.getElementById('proveedoresBtn').addEventListener('click', showProveedoresView); // ============================================================ // Proveedores: saldo (lo que les debes o te deben) + historial de // compras (puedes subir el Excel de tu compra mensual) y pagos. // ============================================================ const PROVEEDORES_KEY = 'proveedores'; const MOVIMIENTOS_PROVEEDOR_KEY = 'movimientos_proveedor'; let proveedorAbiertoId = null; async function getProveedores() { return await getListFromStorage(PROVEEDORES_KEY); } async function getMovimientosProveedor() { return await getListFromStorage(MOVIMIENTOS_PROVEEDOR_KEY); } function calcularSaldoProveedor(proveedorId, movimientos) { let saldo = 0; movimientos.forEach(m => { if (m.proveedorId !== proveedorId) return; if (m.tipo === 'compra') saldo += m.monto; else if (m.tipo === 'pago') saldo -= m.monto; }); return saldo; // > 0 = le debes al proveedor; < 0 = el proveedor te debe a ti (a favor) } async function renderProveedores() { const proveedores = await getProveedores(); const movimientos = await getMovimientosProveedor(); const tabla = document.getElementById('proveedoresTable'); const count = document.getElementById('proveedoresCount'); count.textContent = proveedores.length + ' proveedor' + (proveedores.length === 1 ? '' : 'es'); tabla.innerHTML = ''; if (proveedores.length === 0) { tabla.innerHTML = '

Todavía no has agregado ningún proveedor.
Usa el botón de arriba para empezar.

'; return; } proveedores.forEach(p => { const saldo = calcularSaldoProveedor(p.id, movimientos); const card = document.createElement('div'); card.dataset.proveedorId = p.id; let estado = 'mano', numero = '$0', etiqueta = 'Estás a mano'; if (saldo > 0.5) { estado = 'debo'; numero = money(saldo); etiqueta = 'Le debes'; } else if (saldo < -0.5) { estado = 'a-favor'; numero = money(Math.abs(saldo)); etiqueta = 'Te debe (a favor)'; } card.className = 'proveedor-card ' + estado; card.innerHTML = `
${escapeHtmlPreview(p.nombre)} Toca para ver el historial
${numero} ${etiqueta}
`; tabla.appendChild(card); }); } document.getElementById('proveedoresTable').addEventListener('click', (e) => { const row = e.target.closest('[data-proveedor-id]'); if (!row) return; abrirProveedorDetalle(row.dataset.proveedorId); }); document.getElementById('agregarProveedorBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; const nombre = await pedirTexto('Nombre del proveedor', '', { placeholder: 'ej. Distribuidora XYZ' }); if (!nombre) return; const proveedores = await getProveedores(); proveedores.push({ id: 'prov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), nombre: nombre.trim(), fechaAgregado: new Date().toISOString() }); await setListToStorage(PROVEEDORES_KEY, proveedores); await renderProveedores(); }); async function abrirProveedorDetalle(proveedorId) { const proveedores = await getProveedores(); const proveedor = proveedores.find(p => p.id === proveedorId); if (!proveedor) return; proveedorAbiertoId = proveedorId; document.getElementById('proveedorDetalleNombre').textContent = proveedor.nombre; await renderProveedorDetalle(); ['proveedorAgregarCompraBtn', 'proveedorSubirExcelBtn', 'proveedorRegistrarPagoBtn', 'proveedorEliminarBtn'].forEach(id => { document.getElementById(id).style.display = puedeEditarProveedores ? '' : 'none'; }); document.getElementById('proveedorDetalleModal').style.display = 'flex'; } async function renderProveedorDetalle() { if (!proveedorAbiertoId) return; const movimientos = (await getMovimientosProveedor()).filter(m => m.proveedorId === proveedorAbiertoId); const saldo = calcularSaldoProveedor(proveedorAbiertoId, movimientos); const saldoBox = document.getElementById('proveedorSaldoBox'); const saldoLabel = document.getElementById('proveedorSaldoLabel'); const saldoMonto = document.getElementById('proveedorSaldoMonto'); saldoBox.classList.remove('debo', 'a-favor'); if (saldo > 0.5) { saldoBox.classList.add('debo'); saldoLabel.textContent = 'Le debes a este proveedor'; saldoMonto.textContent = money(saldo); } else if (saldo < -0.5) { saldoBox.classList.add('a-favor'); saldoLabel.textContent = 'Este proveedor te debe (a tu favor)'; saldoMonto.textContent = money(Math.abs(saldo)); } else { saldoLabel.textContent = 'Estás a mano con este proveedor'; saldoMonto.textContent = '$0'; } const tabla = document.getElementById('proveedorMovimientosTable'); tabla.innerHTML = ''; if (movimientos.length === 0) { tabla.innerHTML = '

Todavía no hay compras ni pagos registrados.

'; return; } movimientos.sort((a, b) => new Date(b.fecha) - new Date(a.fecha)).forEach(m => { const row = document.createElement('div'); row.className = 'seller-sale-row'; const fechaStr = m.fecha ? new Date(m.fecha).toLocaleDateString('es-MX') : ''; const esCompra = m.tipo === 'compra'; const tieneListado = esCompra && m.listado && m.listado.filas && m.listado.filas.length > 0; row.style.cursor = 'pointer'; row.dataset.movId = m.id; row.title = 'Toca para ver, editar o borrar este movimiento'; row.innerHTML = `
${esCompra ? 'Compra' : 'Pago'}${m.concepto ? ' — ' + escapeHtmlPreview(m.concepto) : ''}${tieneListado ? ' ' : ''} ${fechaStr}${m.registradoPor ? ' · ' + escapeHtmlPreview(m.registradoPor) : ''}${m.archivo ? ' · ' + escapeHtmlPreview(m.archivo) : ''}
${esCompra ? '+' : '-'}${money(m.monto)}`; tabla.appendChild(row); }); } document.getElementById('proveedorMovimientosTable').addEventListener('click', (e) => { const row = e.target.closest('[data-mov-id]'); if (!row) return; abrirVerListado(row.dataset.movId); }); let verListadoMovId = null; async function abrirVerListado(movId) { const movimientos = await getMovimientosProveedor(); const m = movimientos.find(x => x.id === movId); if (!m) return; verListadoMovId = movId; document.getElementById('verListadoTitulo').textContent = m.tipo === 'compra' ? 'Compra registrada' : 'Pago registrado'; document.getElementById('verListadoFecha').value = m.fecha ? m.fecha.slice(0, 10) : ''; document.getElementById('verListadoTotal').value = m.monto.toFixed(2); document.getElementById('verListadoConcepto').value = m.concepto || ''; const tablaWrap = document.getElementById('verListadoTablaWrap'); if (m.listado && m.listado.filas && m.listado.filas.length > 0) { const headers = m.listado.headers || []; const filas = m.listado.filas || []; let html = ''; headers.forEach(h => { html += ''; }); html += ''; filas.forEach(fila => { html += ''; headers.forEach((h, i) => { html += ''; }); html += ''; }); html += '
' + escapeHtmlPreview(String(h)) + '
' + escapeHtmlPreview(fila[i] !== undefined ? String(fila[i]) : '') + '
'; document.getElementById('verListadoTabla').innerHTML = html; tablaWrap.style.display = ''; } else { document.getElementById('verListadoTabla').innerHTML = ''; tablaWrap.style.display = 'none'; } ['verListadoFecha', 'verListadoTotal', 'verListadoConcepto'].forEach(id => { document.getElementById(id).disabled = !puedeEditarProveedores; }); document.getElementById('verListadoGuardarBtn').style.display = puedeEditarProveedores ? '' : 'none'; document.getElementById('verListadoEliminarBtn').style.display = puedeEditarProveedores ? '' : 'none'; document.getElementById('verListadoModal').style.display = 'flex'; } document.getElementById('verListadoCerrarBtn').addEventListener('click', () => { document.getElementById('verListadoModal').style.display = 'none'; verListadoMovId = null; }); document.getElementById('verListadoGuardarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!verListadoMovId) return; const total = parseFloat(document.getElementById('verListadoTotal').value.replace(/[^0-9.\-]/g, '')); if (!total || total <= 0) { alertBonito('Revisa el total — tiene que ser un número mayor a cero.'); return; } const fechaStr = document.getElementById('verListadoFecha').value; if (!fechaStr) { alertBonito('Elige la fecha.'); return; } const concepto = document.getElementById('verListadoConcepto').value.trim(); const movimientos = await getMovimientosProveedor(); const idx = movimientos.findIndex(x => x.id === verListadoMovId); if (idx === -1) return; movimientos[idx].monto = total; movimientos[idx].fecha = new Date(fechaStr + 'T12:00:00').toISOString(); movimientos[idx].concepto = concepto; await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, movimientos); document.getElementById('verListadoModal').style.display = 'none'; verListadoMovId = null; await renderProveedorDetalle(); await renderProveedores(); alertBonito('Movimiento actualizado.'); }); document.getElementById('verListadoEliminarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!verListadoMovId) return; if (!(await confirmarBonito('Esto quita el monto del saldo del proveedor. No se puede deshacer.', '¿Eliminar este movimiento?'))) return; const movimientos = await getMovimientosProveedor(); const nuevaLista = movimientos.filter(x => x.id !== verListadoMovId); await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, nuevaLista); document.getElementById('verListadoModal').style.display = 'none'; verListadoMovId = null; await renderProveedorDetalle(); await renderProveedores(); alertBonito('Movimiento eliminado.'); }); document.getElementById('proveedorDetalleCerrarBtn').addEventListener('click', () => { document.getElementById('proveedorDetalleModal').style.display = 'none'; proveedorAbiertoId = null; }); const MESES_CORTOS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; const MESES_LARGOS = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; // Agrupa los movimientos de un proveedor por mes (compras, pagos y saldo acumulado). // La usan tanto la pantalla del reporte mensual como los dos exportadores (Excel y PDF), // asi el numero siempre es el mismo se vea donde se vea. function calcularReporteMensualProveedor(movimientos) { const porMes = {}; movimientos.forEach(m => { if (!m.fecha) return; const d = new Date(m.fecha); const clave = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!porMes[clave]) porMes[clave] = { compras: 0, pagos: 0, anio: d.getFullYear(), mes: d.getMonth() }; if (m.tipo === 'compra') porMes[clave].compras += m.monto; else porMes[clave].pagos += m.monto; }); const claves = Object.keys(porMes).sort(); let acumulado = 0; const filas = claves.map(clave => { const d = porMes[clave]; acumulado += d.compras - d.pagos; return { clave, anio: d.anio, mes: d.mes, compras: d.compras, pagos: d.pagos, saldoDelMes: d.compras - d.pagos, saldoAcumulado: acumulado }; }); return filas; } let proveedorReporteActual = null; // guarda {proveedor, movimientos, filasReporte} para los exportadores document.getElementById('proveedorReporteBtn').addEventListener('click', async () => { if (!proveedorAbiertoId) return; const proveedores = await getProveedores(); const proveedor = proveedores.find(p => p.id === proveedorAbiertoId); document.getElementById('proveedorReporteNombre').textContent = proveedor ? proveedor.nombre : ''; const movimientos = (await getMovimientosProveedor()).filter(m => m.proveedorId === proveedorAbiertoId); const filasReporte = calcularReporteMensualProveedor(movimientos); proveedorReporteActual = { proveedor, movimientos, filasReporte }; const tabla = document.getElementById('proveedorReporteTabla'); if (filasReporte.length === 0) { tabla.innerHTML = '

Todavía no hay compras ni pagos registrados.

'; } else { let html = ``; filasReporte.forEach(f => { const colorSaldo = f.saldoAcumulado > 0.5 ? '#f09595' : (f.saldoAcumulado < -0.5 ? '#35c3f0' : '#8891a5'); html += ``; }); html += '
Mes Compras Pagos Saldo acumulado
${MESES_CORTOS[f.mes]} ${f.anio} ${money(f.compras)} ${money(f.pagos)} ${f.saldoAcumulado > 0.5 ? 'Debes ' : (f.saldoAcumulado < -0.5 ? 'A favor ' : '')}${money(Math.abs(f.saldoAcumulado))}
'; tabla.innerHTML = html; } document.getElementById('proveedorReporteMensualModal').style.display = 'flex'; }); document.getElementById('proveedorReporteCerrarBtn').addEventListener('click', () => { document.getElementById('proveedorReporteMensualModal').style.display = 'none'; }); // --- Descargar Excel completo: varias pestañas con todo el detalle (resumen, reporte // mensual, cada movimiento uno por uno, y los productos de los listados que se subieron). --- // Le da buen ancho a las columnas. El "color" en Excel sin pagar licencia se logra con // formato de número condicional (no relleno de celda) — Excel sí sabe pintar un número // de rojo o verde según su signo con solo el código de formato, sin necesitar la versión // de paga de la librería. Se usa aquí a propósito: rojo = aumenta lo que debes (compras), // verde = lo baja (pagos), y en el saldo, rojo si debes / verde si tienes a favor. const FORMATO_ROJO = '[RED]"$"#,##0.00'; const FORMATO_VERDE = '[GREEN]"$"#,##0.00'; const FORMATO_SALDO = '[RED]"$"#,##0.00;[GREEN]-"$"#,##0.00;"$"0.00'; const FORMATO_NEUTRO = '"$"#,##0.00'; function anchoColumnasHoja(ws, anchosColumnas) { ws['!cols'] = anchosColumnas.map(w => ({ wch: w })); } function formatoCelda(ws, r, c, formatCode) { const addr = XLSX.utils.encode_cell({ r, c }); if (ws[addr] && typeof ws[addr].v === 'number') ws[addr].z = formatCode; } document.getElementById('proveedorDescargarExcelBtn').addEventListener('click', () => { if (!proveedorReporteActual) return; try { // Se vuelve a calcular todo de cero en este momento (no se usa nada guardado de antes), // para asegurar que el Excel siempre refleje el estado real y más reciente del proveedor. const { proveedor } = proveedorReporteActual; const movimientos = proveedorReporteActual.movimientos.slice(); const filasReporte = calcularReporteMensualProveedor(movimientos); const saldo = calcularSaldoProveedor(proveedor.id, movimientos); const totalCompras = movimientos.filter(m => m.tipo === 'compra').reduce((s, m) => s + m.monto, 0); const totalPagos = movimientos.filter(m => m.tipo === 'pago').reduce((s, m) => s + m.monto, 0); const ahora = new Date(); const wb = XLSX.utils.book_new(); // --- Hoja 1: Resumen general + tabla mensual (para ver todo de un vistazo) --- const resumen = [ ['REPORTE DE PROVEEDOR'], ['Proveedor', proveedor.nombre], ['Generado el', ahora.toLocaleString('es-MX')], [], ['Total comprado (histórico)', totalCompras], ['Total pagado (histórico)', totalPagos], ['Saldo actual', saldo], ['Situación', saldo > 0.5 ? 'LE DEBES' : (saldo < -0.5 ? 'TE DEBE (A FAVOR)' : 'A MANO')], [], ['RESUMEN MES POR MES — el detalle completo de cada mes (con sus listados) está en su propia pestaña'], ['Mes', 'Compras', 'Pagos', 'Saldo del mes', 'Saldo acumulado'], ]; filasReporte.forEach(f => resumen.push([MESES_LARGOS[f.mes] + ' ' + f.anio, f.compras, f.pagos, f.saldoDelMes, f.saldoAcumulado])); const wsResumen = XLSX.utils.aoa_to_sheet(resumen); anchoColumnasHoja(wsResumen, [50, 20, 16, 16, 18]); formatoCelda(wsResumen, 4, 1, FORMATO_ROJO); // Total comprado formatoCelda(wsResumen, 5, 1, FORMATO_VERDE); // Total pagado formatoCelda(wsResumen, 6, 1, FORMATO_SALDO); // Saldo actual for (let r = 11; r < 11 + filasReporte.length; r++) { formatoCelda(wsResumen, r, 1, FORMATO_ROJO); // Compras del mes formatoCelda(wsResumen, r, 2, FORMATO_VERDE); // Pagos del mes formatoCelda(wsResumen, r, 3, FORMATO_SALDO); // Saldo del mes formatoCelda(wsResumen, r, 4, FORMATO_SALDO); // Saldo acumulado } XLSX.utils.book_append_sheet(wb, wsResumen, 'Resumen'); // --- Una hoja por cada mes con actividad: sus movimientos Y sus listados completos, // tal como los llevarías a mano — todo junto y bien completo. --- const porMesMovs = {}; movimientos.forEach(m => { if (!m.fecha) return; const d = new Date(m.fecha); const clave = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!porMesMovs[clave]) porMesMovs[clave] = { anio: d.getFullYear(), mes: d.getMonth(), movs: [] }; porMesMovs[clave].movs.push(m); }); const clavesOrdenadas = Object.keys(porMesMovs).sort(); let acumuladoCorrido = 0; const nombresHojaUsados = new Set(); clavesOrdenadas.forEach(clave => { const bloque = porMesMovs[clave]; const movsDelMes = bloque.movs.slice().sort((a, b) => new Date(a.fecha) - new Date(b.fecha)); const comprasDelMes = movsDelMes.filter(m => m.tipo === 'compra').reduce((s, m) => s + m.monto, 0); const pagosDelMes = movsDelMes.filter(m => m.tipo === 'pago').reduce((s, m) => s + m.monto, 0); acumuladoCorrido += comprasDelMes - pagosDelMes; const filasHoja = []; let maxCols = 5; const filasConFormatoMonto = []; // { fila, tipo } para pintar despues de cada Monto segun compra/pago filasHoja.push([(MESES_LARGOS[bloque.mes] + ' ' + bloque.anio).toUpperCase()]); filasHoja.push([]); filasHoja.push(['MOVIMIENTOS DEL MES']); filasHoja.push(['Fecha', 'Tipo', 'Concepto', 'Monto', 'Registrado por']); movsDelMes.forEach(m => { filasConFormatoMonto.push({ fila: filasHoja.length, tipo: m.tipo }); filasHoja.push([ m.fecha ? new Date(m.fecha).toLocaleDateString('es-MX') : '', m.tipo === 'compra' ? 'Compra' : 'Pago', m.concepto || '', m.monto, m.registradoPor || '', ]); }); // listados de mercancia de las compras de este mes (si se subieron) const comprasConListado = movsDelMes.filter(m => m.tipo === 'compra' && m.listado && m.listado.filas && m.listado.filas.length > 0); if (comprasConListado.length > 0) { filasHoja.push([]); filasHoja.push(['LISTADO DE MERCANCÍA COMPRADA ESTE MES']); comprasConListado.forEach(m => { filasHoja.push([]); filasHoja.push([(m.concepto || 'Compra') + ' (' + (m.fecha ? new Date(m.fecha).toLocaleDateString('es-MX') : '') + ')']); if (m.listado.headers && m.listado.headers.length > 0) { filasHoja.push(m.listado.headers); maxCols = Math.max(maxCols, m.listado.headers.length); } (m.listado.filas || []).forEach(fila => { filasHoja.push(fila); maxCols = Math.max(maxCols, fila.length); }); }); } filasHoja.push([]); filasHoja.push(['RESUMEN DEL MES']); const filaTotalCompras = filasHoja.length; filasHoja.push(['Total compras', comprasDelMes]); const filaTotalPagos = filasHoja.length; filasHoja.push(['Total pagos', pagosDelMes]); const filaSaldoMes = filasHoja.length; filasHoja.push(['Saldo del mes', comprasDelMes - pagosDelMes]); const filaSaldoAcum = filasHoja.length; filasHoja.push(['Saldo acumulado a la fecha', acumuladoCorrido]); // nombre de hoja valido en Excel: maximo 31 caracteres, sin : \ / ? * [ ] let base = (MESES_CORTOS[bloque.mes] + ' ' + bloque.anio).slice(0, 31); let nombreHoja = base, sufijo = 1; while (nombresHojaUsados.has(nombreHoja)) { sufijo++; nombreHoja = (base + ' (' + sufijo + ')').slice(0, 31); } nombresHojaUsados.add(nombreHoja); const wsMes = XLSX.utils.aoa_to_sheet(filasHoja); const anchos = [26, 12, 34, 16, 20, 16, 16, 16].slice(0, Math.max(maxCols, 5)); anchoColumnasHoja(wsMes, anchos); filasConFormatoMonto.forEach(({ fila, tipo }) => formatoCelda(wsMes, fila, 3, tipo === 'compra' ? FORMATO_ROJO : FORMATO_VERDE)); formatoCelda(wsMes, filaTotalCompras, 1, FORMATO_ROJO); formatoCelda(wsMes, filaTotalPagos, 1, FORMATO_VERDE); formatoCelda(wsMes, filaSaldoMes, 1, FORMATO_SALDO); formatoCelda(wsMes, filaSaldoAcum, 1, FORMATO_SALDO); XLSX.utils.book_append_sheet(wb, wsMes, nombreHoja); }); const nombreArchivo = 'Reporte_' + proveedor.nombre.replace(/[^a-z0-9]+/gi, '_') + '_' + ahora.toISOString().slice(0, 10) + '.xlsx'; XLSX.writeFile(wb, nombreArchivo); alertBonito('Excel descargado con ' + (clavesOrdenadas.length + 1) + ' pestaña(s): Resumen + un mes por cada uno con actividad.'); } catch (e) { alertBonito('No se pudo generar el Excel. Intenta de nuevo.'); } }); // --- Descargar PDF con gráficas: usa Chart.js para dibujar barras (compras vs pagos por // mes) y una línea (saldo acumulado), y las mete en un PDF junto con la tabla mensual. --- document.getElementById('proveedorDescargarPdfBtn').addEventListener('click', async () => { if (!proveedorReporteActual) return; const { proveedor, movimientos, filasReporte } = proveedorReporteActual; if (filasReporte.length === 0) { alertBonito('Todavía no hay datos suficientes para armar las gráficas.'); return; } const btn = document.getElementById('proveedorDescargarPdfBtn'); btn.disabled = true; try { const etiquetas = filasReporte.map(f => MESES_CORTOS[f.mes] + ' ' + String(f.anio).slice(2)); // Los dos canvas se conectan TEMPORALMENTE a la página, fuera de la vista (algunas // versiones de la librería de gráficas no dibujan bien, o fallan sin avisar, si el // canvas nunca estuvo conectado al documento) — se desconectan justo despues de // capturar la imagen. const contenedorTemporal = document.createElement('div'); contenedorTemporal.style.cssText = 'position:fixed; left:-9999px; top:0;'; document.body.appendChild(contenedorTemporal); // grafica de barras: compras vs pagos por mes const canvasBarras = document.createElement('canvas'); canvasBarras.width = 900; canvasBarras.height = 420; contenedorTemporal.appendChild(canvasBarras); const chartBarras = new Chart(canvasBarras.getContext('2d'), { type: 'bar', data: { labels: etiquetas, datasets: [ { label: 'Compras', data: filasReporte.map(f => f.compras), backgroundColor: '#e2504a' }, { label: 'Pagos', data: filasReporte.map(f => f.pagos), backgroundColor: '#35c3f0' }, ], }, options: { responsive: false, animation: false, plugins: { title: { display: true, text: 'Compras vs. pagos por mes', font: { size: 18 } }, legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } }, }, }); // grafica de linea: saldo acumulado a traves del tiempo const canvasLinea = document.createElement('canvas'); canvasLinea.width = 900; canvasLinea.height = 420; contenedorTemporal.appendChild(canvasLinea); const chartLinea = new Chart(canvasLinea.getContext('2d'), { type: 'line', data: { labels: etiquetas, datasets: [{ label: 'Saldo acumulado (+ le debes / − a favor)', data: filasReporte.map(f => f.saldoAcumulado), borderColor: '#4d7fe0', backgroundColor: 'rgba(77,127,224,0.15)', fill: true, tension: 0.25, }], }, options: { responsive: false, animation: false, plugins: { title: { display: true, text: 'Saldo acumulado a través del tiempo', font: { size: 18 } }, legend: { display: false } }, }, }); // esperar un instante a que ambas graficas terminen de dibujarse antes de capturarlas await new Promise(r => setTimeout(r, 300)); const imgBarras = canvasBarras.toDataURL('image/png'); const imgLinea = canvasLinea.toDataURL('image/png'); chartBarras.destroy(); chartLinea.destroy(); contenedorTemporal.remove(); const doc = new jsPDF({ unit: 'mm', format: 'letter' }); const saldo = calcularSaldoProveedor(proveedor.id, proveedorReporteActual.movimientos); const totalCompras = filasReporte.reduce((s, f) => s + f.compras, 0); const totalPagos = filasReporte.reduce((s, f) => s + f.pagos, 0); const colorTeal = [42, 180, 148], colorRojo = [200, 60, 55], colorVerde = [30, 150, 110], colorGris = [110, 118, 136]; // --- Encabezado con logo, nombre de la tienda y título, sobre una franja de color --- function dibujarEncabezado(titulo) { doc.setFillColor(19, 26, 43); doc.rect(0, 0, 216, 30, 'F'); try { doc.addImage('data:image/png;base64,' + LOGO_B64, 'PNG', 12, 6, 18, 18); } catch (e) {} doc.setFont('helvetica', 'bold'); doc.setFontSize(13); doc.setTextColor(255, 255, 255); doc.text(STORE_NAME, 34, 14); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(190, 196, 210); doc.text(STORE_ADDRESS + ' · ' + STORE_PHONE, 34, 20); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor(...colorTeal); doc.text(titulo, 204, 14, { align: 'right' }); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(190, 196, 210); doc.text(new Date().toLocaleDateString('es-MX'), 204, 20, { align: 'right' }); doc.setTextColor(0); } // --- Pie de página con número de hoja, se agrega al final a TODAS las paginas --- function dibujarPies() { const total = doc.internal.getNumberOfPages(); for (let i = 1; i <= total; i++) { doc.setPage(i); doc.setDrawColor(225, 225, 230); doc.line(12, 282, 204, 282); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.setTextColor(140, 146, 160); doc.text('Reporte de proveedor — ' + proveedor.nombre, 12, 287); doc.text('Página ' + i + ' de ' + total, 204, 287, { align: 'right' }); } doc.setTextColor(0); } dibujarEncabezado('REPORTE DE PROVEEDOR'); doc.setFont('helvetica', 'bold'); doc.setFontSize(16); doc.setTextColor(30, 36, 55); doc.text(proveedor.nombre, 105, 42, { align: 'center' }); doc.setTextColor(0); // --- Franja de saldo, con su propio color segun la situacion --- const colorSaldo = saldo > 0.5 ? colorRojo : (saldo < -0.5 ? colorVerde : colorGris); doc.setFillColor(colorSaldo[0], colorSaldo[1], colorSaldo[2]); doc.roundedRect(12, 48, 192, 22, 2, 2, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(255, 255, 255); doc.text((saldo > 0.5 ? 'LE DEBES A ESTE PROVEEDOR' : (saldo < -0.5 ? 'ESTE PROVEEDOR TE DEBE (A FAVOR)' : 'ESTÁS A MANO CON ESTE PROVEEDOR')), 105, 57, { align: 'center' }); doc.setFontSize(17); doc.text(money(Math.abs(saldo)), 105, 66, { align: 'center' }); doc.setTextColor(0); // --- Totales, en dos cajas pequenas --- doc.setFillColor(244, 246, 250); doc.roundedRect(12, 74, 93, 16, 2, 2, 'F'); doc.roundedRect(111, 74, 93, 16, 2, 2, 'F'); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...colorGris); doc.text('TOTAL COMPRADO (HISTÓRICO)', 18, 81); doc.text('TOTAL PAGADO (HISTÓRICO)', 117, 81); doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(...colorRojo); doc.text(money(totalCompras), 18, 88); doc.setTextColor(...colorVerde); doc.text(money(totalPagos), 117, 88); doc.setTextColor(0); doc.addImage(imgBarras, 'PNG', 12, 96, 187, 82); doc.addImage(imgLinea, 'PNG', 12, 182, 187, 82); doc.addPage(); dibujarEncabezado('REPORTE MENSUAL DETALLADO'); let y = 40; doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(255, 255, 255); doc.setFillColor(...colorTeal); doc.rect(12, y - 5, 192, 8, 'F'); doc.text('MES', 15, y); doc.text('COMPRAS', 100, y, { align: 'right' }); doc.text('PAGOS', 140, y, { align: 'right' }); doc.text('SALDO ACUMULADO', 201, y, { align: 'right' }); doc.setTextColor(0); y += 8; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); filasReporte.forEach((f, i) => { if (y > 270) { doc.addPage(); dibujarEncabezado('REPORTE MENSUAL DETALLADO'); y = 40; } if (i % 2 === 0) { doc.setFillColor(246, 247, 250); doc.rect(12, y - 4.5, 192, 6.5, 'F'); } doc.setTextColor(30, 36, 55); doc.text(MESES_LARGOS[f.mes] + ' ' + f.anio, 15, y); doc.setTextColor(...colorRojo); doc.text(money(f.compras), 100, y, { align: 'right' }); doc.setTextColor(...colorVerde); doc.text(money(f.pagos), 140, y, { align: 'right' }); doc.setTextColor(f.saldoAcumulado > 0.5 ? colorRojo[0] : (f.saldoAcumulado < -0.5 ? colorVerde[0] : colorGris[0]), f.saldoAcumulado > 0.5 ? colorRojo[1] : (f.saldoAcumulado < -0.5 ? colorVerde[1] : colorGris[1]), f.saldoAcumulado > 0.5 ? colorRojo[2] : (f.saldoAcumulado < -0.5 ? colorVerde[2] : colorGris[2])); doc.setFont('helvetica', 'bold'); doc.text((f.saldoAcumulado > 0.5 ? '+' : '') + money(f.saldoAcumulado), 201, y, { align: 'right' }); doc.setFont('helvetica', 'normal'); y += 6.5; }); doc.setTextColor(0); // --- Listado de mercancía comprada, mes por mes (para que el PDF quede tan completo // como el Excel) — solo se listan los meses que tienen un listado adjunto. --- const porMesParaListado = {}; movimientos.forEach(m => { if (m.tipo !== 'compra' || !m.listado || !m.listado.filas || m.listado.filas.length === 0 || !m.fecha) return; const d = new Date(m.fecha); const clave = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); if (!porMesParaListado[clave]) porMesParaListado[clave] = { anio: d.getFullYear(), mes: d.getMonth(), compras: [] }; porMesParaListado[clave].compras.push(m); }); const clavesConListado = Object.keys(porMesParaListado).sort(); if (clavesConListado.length > 0) { doc.addPage(); dibujarEncabezado('MERCANCÍA COMPRADA, MES POR MES'); y = 40; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(0); clavesConListado.forEach(clave => { const bloque = porMesParaListado[clave]; if (y > 260) { doc.addPage(); dibujarEncabezado('MERCANCÍA COMPRADA, MES POR MES'); y = 40; } doc.setFillColor(...colorTeal); doc.roundedRect(12, y - 5, 60, 7, 1.5, 1.5, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(9.5); doc.setTextColor(255, 255, 255); doc.text(MESES_LARGOS[bloque.mes] + ' ' + bloque.anio, 15, y); doc.setTextColor(0); y += 8; bloque.compras.forEach(m => { if (y > 270) { doc.addPage(); dibujarEncabezado('MERCANCÍA COMPRADA, MES POR MES'); y = 40; } doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.text((m.concepto || 'Compra') + ' (' + new Date(m.fecha).toLocaleDateString('es-MX') + ' — ' + money(m.monto) + ')', 15, y); y += 5.5; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(60, 66, 84); (m.listado.filas || []).forEach(fila => { if (y > 275) { doc.addPage(); dibujarEncabezado('MERCANCÍA COMPRADA, MES POR MES'); y = 40; } const texto = fila.filter(v => v !== undefined && v !== '').join(' · '); const lineas = doc.splitTextToSize('• ' + texto, 178); lineas.forEach(linea => { doc.text(linea, 18, y); y += 4.2; }); }); doc.setTextColor(0); y += 3; }); y += 3; }); } dibujarPies(); doc.save('Reporte_' + proveedor.nombre.replace(/[^a-z0-9]+/gi, '_') + '_' + new Date().toISOString().slice(0, 10) + '.pdf'); } catch (e) { alertBonito('No se pudo generar el PDF.\n\nDetalle técnico: ' + (e && e.message ? e.message : e)); } finally { btn.disabled = false; } }); document.getElementById('proveedorEliminarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!proveedorAbiertoId) return; const proveedores = await getProveedores(); const proveedor = proveedores.find(p => p.id === proveedorAbiertoId); if (!proveedor) return; if (!(await confirmarBonito('Se borra el proveedor y todo su historial de compras y pagos. Esto no se puede deshacer.', '¿Eliminar a "' + proveedor.nombre + '"?'))) return; const nuevaLista = proveedores.filter(p => p.id !== proveedorAbiertoId); await setListToStorage(PROVEEDORES_KEY, nuevaLista); const movimientos = await getMovimientosProveedor(); const nuevosMovimientos = movimientos.filter(m => m.proveedorId !== proveedorAbiertoId); await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, nuevosMovimientos); document.getElementById('proveedorDetalleModal').style.display = 'none'; proveedorAbiertoId = null; await renderProveedores(); }); document.getElementById('proveedorRegistrarPagoBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!proveedorAbiertoId) return; const montoStr = await pedirTexto('Monto que le entregaste al proveedor (MXN)', '', { numerico: true }); if (montoStr === null) return; const monto = parseFloat(montoStr.replace(/[^0-9.]/g, '')) || 0; if (monto <= 0) return; const concepto = (await pedirTexto('Concepto (opcional)', '', { placeholder: 'ej. Pago de mayo' })) || ''; const movimientos = await getMovimientosProveedor(); movimientos.push({ id: 'mov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), proveedorId: proveedorAbiertoId, tipo: 'pago', monto, concepto, fecha: new Date().toISOString(), registradoPor: currentSession ? currentSession.nombre : 'N/D', }); await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, movimientos); await renderProveedorDetalle(); await renderProveedores(); }); // --- Agregar mercancía nueva a mano: registra la compra (con su fecha/mes elegido) y, // si se agregan productos a la lista, tambien los mete al inventario — mismo resultado // que subir un Excel, pero escribiendo directo en vez de necesitar el archivo. --- let compraManualProductos = []; document.getElementById('proveedorAgregarCompraBtn').addEventListener('click', () => { if (!puedeEditarProveedores) return; if (!proveedorAbiertoId) return; document.getElementById('compraManualFecha').value = new Date().toISOString().slice(0, 10); document.getElementById('compraManualTotal').value = ''; document.getElementById('compraManualConcepto').value = ''; document.getElementById('compraManualProdNombre').value = ''; document.getElementById('compraManualProdPiezas').value = ''; const selectCat = document.getElementById('compraManualProdCategoria'); selectCat.innerHTML = CATEGORIAS_INVENTARIO.map(c => '').join(''); compraManualProductos = []; renderCompraManualProductos(); document.getElementById('compraManualModal').style.display = 'flex'; }); document.getElementById('compraManualCancelarBtn').addEventListener('click', () => { document.getElementById('compraManualModal').style.display = 'none'; }); function renderCompraManualProductos() { const cont = document.getElementById('compraManualProdLista'); if (compraManualProductos.length === 0) { cont.innerHTML = ''; return; } cont.innerHTML = compraManualProductos.map((p, i) => `
${p.piezas} × ${escapeHtmlPreview(p.nombre)} (${escapeHtmlPreview(p.categoria)})
` ).join(''); cont.querySelectorAll('.quitar-btn').forEach(btn => { btn.addEventListener('click', () => { compraManualProductos.splice(parseInt(btn.dataset.idx, 10), 1); renderCompraManualProductos(); }); }); } document.getElementById('compraManualProdAgregarBtn').addEventListener('click', () => { const nombre = document.getElementById('compraManualProdNombre').value.trim(); if (!nombre) return; const piezas = Math.max(1, parseInt(document.getElementById('compraManualProdPiezas').value, 10) || 1); const categoria = document.getElementById('compraManualProdCategoria').value; compraManualProductos.push({ nombre, piezas, categoria }); document.getElementById('compraManualProdNombre').value = ''; document.getElementById('compraManualProdPiezas').value = ''; renderCompraManualProductos(); document.getElementById('compraManualProdNombre').focus(); }); document.getElementById('compraManualGuardarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!proveedorAbiertoId) return; const fechaStr = document.getElementById('compraManualFecha').value; if (!fechaStr) { alertBonito('Elige la fecha de la compra.'); return; } const total = parseFloat(document.getElementById('compraManualTotal').value.replace(/[^0-9.]/g, '')) || 0; if (total <= 0) { alertBonito('Escribe el total de la compra.'); return; } const concepto = document.getElementById('compraManualConcepto').value.trim(); const fechaIso = new Date(fechaStr + 'T12:00:00').toISOString(); const registradoPor = currentSession ? currentSession.nombre : 'N/D'; const movimientos = await getMovimientosProveedor(); movimientos.push({ id: 'mov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), proveedorId: proveedorAbiertoId, tipo: 'compra', monto: total, concepto: concepto || 'Compra de mercancía', fecha: fechaIso, registradoPor, listado: compraManualProductos.length > 0 ? { headers: ['Producto', 'Piezas', 'Categoría'], filas: compraManualProductos.map(p => [p.nombre, p.piezas, p.categoria]) } : undefined, }); await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, movimientos); let cuantosProductos = 0; if (compraManualProductos.length > 0) { const cuantasLaptops = compraManualProductos.filter(p => p.categoria === 'Laptops').reduce((s, p) => s + p.piezas, 0); const idsLaptop = cuantasLaptops > 0 ? await reservarIdsLaptopSecuenciales(cuantasLaptops) : []; let cursorLaptop = 0; let extras = []; try { extras = await getListFromStorage(EXTRA_INV_KEY); } catch (e) { extras = []; } const nuevos = []; compraManualProductos.forEach(p => { for (let i = 0; i < p.piezas; i++) { const id = p.categoria === 'Laptops' ? idsLaptop[cursorLaptop++] : generarIdAleatorioGeneral(); const item = { id, anaquel: '', nivel: '', categoria: p.categoria, marca: p.nombre, modelo: '', procesador: '', ram: '', almacenamiento: '', touch: '', gpu: '', precioBueno: 0, precioCosmetico: 0, etiquetado: false, fechaAgregado: fechaIso, agregadoPor: registradoPor, estadoEquipo: 'pendiente', registradoDesde: 'proveedor-manual', }; nuevos.push(item); DATA.push(item); INDEX[id.toUpperCase()] = item; } }); extras = extras.concat(nuevos); try { await setListToStorage(EXTRA_INV_KEY, extras); } catch (e) {} try { const registros = await getListFromStorage('registro_tecnicos'); registros.push({ quien: registradoPor, cantidad: nuevos.length, fecha: fechaIso, productos: nuevos.map(it => ({ id: it.id, nombre: it.marca })), origen: 'proveedor-manual', }); await setListToStorage('registro_tecnicos', registros); } catch (e) {} cuantosProductos = nuevos.length; updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); } document.getElementById('compraManualModal').style.display = 'none'; await renderProveedorDetalle(); await renderProveedores(); alertBonito('Compra de ' + money(total) + ' agregada al saldo' + (cuantosProductos > 0 ? ', y se agregaron ' + cuantosProductos + ' producto(s) al inventario (precio y ubicación pendientes).' : '.')); }); // --- Subir Excel de compra --------------------------------------------------------- // Primero intenta reconocer un formato de "varios meses en un mismo archivo" (como // bloques "ENERO 2026 COMPRAS", cada uno con su "TOTAL ENTREGADO" y su total de // compras) — si lo encuentra, se revisan TODOS los meses de un jalón. Si el archivo no // trae ese formato (una tabla sencilla y plana), cae de respaldo al modo de "elige la // columna del monto" que ya existía. En ambos casos, SIEMPRE se muestra una vista // previa y se pide confirmar antes de aplicar nada — nunca se suma y se guarda a ciegas. let excelFilasActuales = []; let excelHeadersActuales = []; let excelMesesDetectados = []; let excelArchivoNombreActual = ''; document.getElementById('proveedorSubirExcelBtn').addEventListener('click', () => { document.getElementById('proveedorExcelInput').click(); }); document.getElementById('proveedorExcelInput').addEventListener('change', (e) => { const file = e.target.files[0]; e.target.value = ''; if (!file) return; excelArchivoNombreActual = file.name; const reader = new FileReader(); reader.onload = (ev) => { try { const datos = new Uint8Array(ev.target.result); const libro = XLSX.read(datos, { type: 'array' }); const hoja = libro.Sheets[libro.SheetNames[0]]; const matriz = XLSX.utils.sheet_to_json(hoja, { header: 1, defval: '' }); if (!matriz || matriz.length === 0) { alertBonito('El archivo parece estar vacío.'); return; } const meses = detectarMesesEnExcel(matriz); if (meses.length > 0) { abrirVistaPreviaMultiMes(meses); } else { excelHeadersActuales = (matriz[0] || []).map((h, i) => (h === '' || h === undefined) ? ('Columna ' + (i + 1)) : String(h)); excelFilasActuales = matriz.slice(1).filter(f => f.some(v => v !== '')); abrirVistaPreviaExcel(file.name); } } catch (err) { alertBonito('No se pudo leer ese archivo. Revisa que sea un Excel (.xlsx) o CSV válido.'); } }; reader.readAsArrayBuffer(file); }); // Busca bloques de mes (celdas de la forma "ENERO 2026 COMPRAS") y, dentro de cada uno, // el "TOTAL ENTREGADO" y los posibles totales de compras del mes (puede haber más de un // candidato, ej. "Total en MXN" y "TOTAL GENERAL" — se muestran ambos para que se elija). function detectarMesesEnExcel(matriz) { const nFilas = matriz.length; const nCols = matriz.reduce((max, f) => Math.max(max, f.length), 0); const val = (r, c) => (r >= 0 && r < nFilas && matriz[r] && c >= 0 && c < matriz[r].length) ? matriz[r][c] : undefined; const esNumero = (v) => typeof v === 'number' && !isNaN(v); const MESES_NOMBRES = ['ENERO', 'FEBRERO', 'MARZO', 'ABRIL', 'MAYO', 'JUNIO', 'JULIO', 'AGOSTO', 'SEPTIEMBRE', 'OCTUBRE', 'NOVIEMBRE', 'DICIEMBRE']; const reMes = /^([A-ZÁÉÍÓÚÑ]+)\s+(\d{4})\s+COMPRAS$/i; const reEntregado = /total\s*entregado/i; const reCompra = /compras?\s*\(mxn\)|total\s*compras|total\s*en\s*mxn|total\s*mxn|total\s*general/i; const bloques = []; for (let r = 0; r < nFilas; r++) { const v = val(r, 0); if (typeof v === 'string') { const m = reMes.exec(v.trim()); if (m) bloques.push({ fila: r, mesTexto: m[1].toUpperCase(), anio: m[2], nombre: v.trim() }); } } if (bloques.length === 0) return []; bloques.push({ fila: nFilas, nombre: 'FIN' }); const resultado = []; for (let i = 0; i < bloques.length - 1; i++) { const inicio = bloques[i].fila, fin = bloques[i + 1].fila; let entregado = null; const compraCandidatos = []; const vistos = new Set(); for (let r = inicio; r < fin; r++) { for (let c = 0; c < nCols; c++) { const v = val(r, c); if (typeof v !== 'string') continue; const vNum = val(r, c + 1); if (reEntregado.test(v) && esNumero(vNum) && entregado === null) entregado = vNum; if (reCompra.test(v) && esNumero(vNum)) { const clave = v.trim() + '|' + vNum; if (!vistos.has(clave)) { vistos.add(clave); compraCandidatos.push({ etiqueta: v.trim(), valor: vNum }); } } } } if (entregado !== null || compraCandidatos.length > 0) { const mesIdx = MESES_NOMBRES.indexOf(bloques[i].mesTexto); resultado.push({ nombre: bloques[i].nombre, mesIdx: mesIdx >= 0 ? mesIdx : 0, anio: parseInt(bloques[i].anio, 10) || new Date().getFullYear(), entregado: entregado, compraCandidatos, productos: extraerProductosDelBloque(matriz, inicio, fin, val, nCols), }); } } return resultado; } // Dentro de un bloque de mes, busca la fila de encabezado (la que dice "Producto" o // "Concepto") y de ahi para abajo junta cada linea como un producto comprado, con sus // piezas (si el archivo no trae columna de piezas, como en algunos meses, se asume 1 // pieza por linea). Ignora las filas de "TOTAL..." y las que no tienen nombre. function extraerProductosDelBloque(matriz, inicio, fin, val, nCols) { const reHeader = /producto|concepto/i; const rePiezas = /piezas/i; const reTotal = /^total/i; let filaHeader = -1, colNombre = -1, colPiezas = -1; for (let r = inicio; r < Math.min(inicio + 8, fin) && filaHeader === -1; r++) { for (let c = 0; c < nCols; c++) { const v = val(r, c); if (typeof v === 'string' && reHeader.test(v)) { filaHeader = r; colNombre = c; break; } } } if (filaHeader === -1) return []; for (let c = 0; c < nCols; c++) { const v = val(filaHeader, c); if (typeof v === 'string' && rePiezas.test(v)) { colPiezas = c; break; } } const items = []; for (let r = filaHeader + 1; r < fin; r++) { const nombre = val(r, colNombre); if (typeof nombre !== 'string' || !nombre.trim()) continue; if (reTotal.test(nombre.trim())) continue; let piezas = colPiezas !== -1 ? val(r, colPiezas) : 1; if (typeof piezas !== 'number' || isNaN(piezas) || piezas <= 0) piezas = 1; items.push({ nombre: nombre.trim(), piezas: Math.round(piezas) }); } return items; } // Palabras que casi siempre son gastos del viaje de compra, no mercancia para vender — // se muestran igual (por si acaso), pero empiezan destildadas. const EXCEL_PALABRAS_NO_MERCANCIA = /gasolina|salida|aduana|cruce|vi[aá]tico|hotel|insumo|flete|env[ií]o|^lote\b/i; function adivinarCategoriaProducto(nombre) { const n = nombre.toLowerCase(); if (/laptop|lenovo|dell|hp\s|surface|thinkpad|latitude|elitebook|probook|macbook|chromebook|firefly|gamer\b/.test(n)) return 'Laptops'; if (/monitor/.test(n)) return 'Monitores'; if (/cargador/.test(n)) return 'Cargadores'; if (/teclado/.test(n)) return 'Teclados'; if (/mouse|rat[oó]n/.test(n)) return 'Mouses'; if (/bocina|speaker/.test(n)) return 'Bocinas'; if (/proyector/.test(n)) return 'Proyectores'; if (/core\s*i\d|xeon|ryzen|celeron|\bi3-|\bi5-|\bi7-|\bi9-|\bcpu\b/i.test(nombre)) return 'CPUs'; return 'Otros'; } function abrirVistaPreviaMultiMes(meses) { excelMesesDetectados = meses; document.getElementById('excelMultiMesArchivo').textContent = 'Archivo: ' + excelArchivoNombreActual + ' — se detectaron ' + meses.length + ' mes(es). Revisa cada uno antes de aplicar.'; const lista = document.getElementById('excelMultiMesLista'); lista.innerHTML = meses.map((m, i) => { const compraDefault = m.compraCandidatos.length > 0 ? m.compraCandidatos[0].valor : 0; const selectorCandidatos = m.compraCandidatos.length > 1 ? `` : (m.compraCandidatos.length === 1 ? `

Detectado en: "${escapeHtmlPreview(m.compraCandidatos[0].etiqueta)}"

` : `

No se encontró un total de compras para este mes — escríbelo a mano.

`); return `
${escapeHtmlPreview(m.nombre)}
${selectorCandidatos}
`; }).join(''); lista.querySelectorAll('.excel-mes-candidato-select').forEach(sel => { sel.addEventListener('change', (e) => { const idx = parseInt(e.target.dataset.idx, 10); const ci = parseInt(e.target.value, 10); const valor = excelMesesDetectados[idx].compraCandidatos[ci].valor; lista.querySelector('.excel-mes-row[data-idx="' + idx + '"] .excel-mes-compra-input').value = valor.toFixed(2); }); }); document.getElementById('excelMultiMesModal').style.display = 'flex'; } document.getElementById('excelMultiMesCancelarBtn').addEventListener('click', () => { document.getElementById('excelMultiMesModal').style.display = 'none'; }); document.getElementById('excelMultiMesAplicarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!proveedorAbiertoId) return; let movimientos = await getMovimientosProveedor(); // Si este mismo archivo ya se había subido antes para este proveedor, ofrece // reemplazar esos registros viejos por los nuevos (para no duplicar el saldo, y para // que si antes se subió sin listado completo, ahora sí quede guardado con todo). const yaExistian = movimientos.filter(m => m.proveedorId === proveedorAbiertoId && m.archivo === excelArchivoNombreActual); if (yaExistian.length > 0) { const reemplazar = await confirmarBonito( 'Ya habías subido "' + excelArchivoNombreActual + '" antes (' + yaExistian.length + ' registro(s) guardados de esa vez). Si subes los mismos meses otra vez, el saldo se duplicaría.', '¿Reemplazar esos registros anteriores por estos nuevos?' ); if (reemplazar) { movimientos = movimientos.filter(m => !(m.proveedorId === proveedorAbiertoId && m.archivo === excelArchivoNombreActual)); } else { document.getElementById('excelMultiMesModal').style.display = 'none'; alertBonito('No se aplicó nada, para no duplicar el saldo.'); return; } } const filas = Array.from(document.querySelectorAll('#excelMultiMesLista .excel-mes-row')); let cuantos = 0; filas.forEach(row => { const idx = parseInt(row.dataset.idx, 10); const incluir = row.querySelector('.excel-mes-incluir').checked; if (!incluir) return; const m = excelMesesDetectados[idx]; const compra = parseFloat(row.querySelector('.excel-mes-compra-input').value.replace(/[^0-9.\-]/g, '')) || 0; const entregado = parseFloat(row.querySelector('.excel-mes-entregado-input').value.replace(/[^0-9.\-]/g, '')) || 0; const fechaMes = new Date(m.anio, m.mesIdx, 1).toISOString(); const registradoPor = currentSession ? currentSession.nombre : 'N/D'; if (compra > 0) { movimientos.push({ id: 'mov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + cuantos, proveedorId: proveedorAbiertoId, tipo: 'compra', monto: compra, concepto: 'Compra de mercancía — ' + m.nombre, fecha: fechaMes, registradoPor, archivo: excelArchivoNombreActual, listado: (m.productos && m.productos.length > 0) ? { headers: ['Producto', 'Piezas'], filas: m.productos.map(p => [p.nombre, p.piezas]) } : undefined, }); } if (entregado > 0) { movimientos.push({ id: 'mov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + cuantos + 'p', proveedorId: proveedorAbiertoId, tipo: 'pago', monto: entregado, concepto: 'Dinero entregado — ' + m.nombre, fecha: fechaMes, registradoPor, archivo: excelArchivoNombreActual, }); } cuantos++; }); if (cuantos === 0) { alertBonito('No marcaste ningún mes para aplicar.'); return; } await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, movimientos); document.getElementById('excelMultiMesModal').style.display = 'none'; await renderProveedorDetalle(); await renderProveedores(); const incluirProductos = document.getElementById('excelIncluirProductos').checked; const mesesIncluidos = filas.filter(row => row.querySelector('.excel-mes-incluir').checked) .map(row => excelMesesDetectados[parseInt(row.dataset.idx, 10)]); const productosParaRevisar = []; mesesIncluidos.forEach(m => { (m.productos || []).forEach(p => productosParaRevisar.push({ mes: m.nombre, nombre: p.nombre, piezas: p.piezas })); }); if (incluirProductos && productosParaRevisar.length > 0) { abrirVistaPreviaProductos(productosParaRevisar); } else { alertBonito('Se aplicaron ' + cuantos + ' mes(es) al historial del proveedor.'); } }); // --- Revisión de la mercancía comprada (productos detectados en el Excel) antes de // agregarla al inventario — nunca se agrega nada sin que la persona la revise primero. --- let excelProductosActuales = []; function abrirVistaPreviaProductos(productos) { excelProductosActuales = productos; document.getElementById('excelProductosResumen').textContent = 'Se encontraron ' + productos.length + ' línea(s) de mercancía. Revísalas antes de agregarlas al inventario.'; let mesActual = null; const lista = document.getElementById('excelProductosLista'); lista.innerHTML = productos.map((p, i) => { const marcarChecked = !EXCEL_PALABRAS_NO_MERCANCIA.test(p.nombre) ? 'checked' : ''; const categoria = adivinarCategoriaProducto(p.nombre); const tituloMes = p.mes !== mesActual ? `
${escapeHtmlPreview(p.mes)}
` : ''; mesActual = p.mes; return tituloMes + `
`; }).join(''); document.getElementById('excelProductosModal').style.display = 'flex'; } document.getElementById('excelProductosOmitirBtn').addEventListener('click', () => { document.getElementById('excelProductosModal').style.display = 'none'; alertBonito('Se aplicaron los meses al historial del proveedor. No se agregó mercancía al inventario.'); }); document.getElementById('excelProductosAgregarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; const filas = Array.from(document.querySelectorAll('#excelProductosLista .excel-prod-row')); const marcadas = filas.filter(row => row.querySelector('.excel-prod-incluir').checked).map(row => { const idx = parseInt(row.dataset.idx, 10); return { nombre: row.querySelector('.excel-prod-nombre').value.trim() || excelProductosActuales[idx].nombre, piezas: Math.max(1, parseInt(row.querySelector('.excel-prod-piezas').value, 10) || 1), categoria: row.querySelector('.excel-prod-categoria').value, }; }).filter(p => p.nombre); if (marcadas.length === 0) { alertBonito('No marcaste ninguna línea para agregar.'); return; } const totalPiezas = marcadas.reduce((s, p) => s + p.piezas, 0); const cuantasLaptops = marcadas.filter(p => p.categoria === 'Laptops').reduce((s, p) => s + p.piezas, 0); const idsLaptop = cuantasLaptops > 0 ? await reservarIdsLaptopSecuenciales(cuantasLaptops) : []; let cursorLaptop = 0; let extras = []; try { extras = await getListFromStorage(EXTRA_INV_KEY); } catch (e) { extras = []; } const ahora = new Date().toISOString(); const registradoPor = currentSession ? currentSession.nombre : 'N/D'; const nuevos = []; marcadas.forEach(p => { for (let i = 0; i < p.piezas; i++) { const id = p.categoria === 'Laptops' ? idsLaptop[cursorLaptop++] : generarIdAleatorioGeneral(); const item = { id, anaquel: '', nivel: '', categoria: p.categoria, marca: p.nombre, modelo: '', procesador: '', ram: '', almacenamiento: '', touch: '', gpu: '', precioBueno: 0, precioCosmetico: 0, etiquetado: false, fechaAgregado: ahora, agregadoPor: registradoPor, estadoEquipo: 'pendiente', registradoDesde: 'proveedor-excel', }; nuevos.push(item); DATA.push(item); INDEX[id.toUpperCase()] = item; } }); extras = extras.concat(nuevos); try { await setListToStorage(EXTRA_INV_KEY, extras); } catch (e) {} try { const registros = await getListFromStorage('registro_tecnicos'); registros.push({ quien: registradoPor, cantidad: nuevos.length, fecha: ahora, productos: nuevos.map(it => ({ id: it.id, nombre: it.marca })), origen: 'proveedor-excel', }); await setListToStorage('registro_tecnicos', registros); } catch (e) {} document.getElementById('excelProductosModal').style.display = 'none'; updateLoadedCount(); if (listView.classList.contains('show')) renderList(listSearch.value); alertBonito('Se agregaron ' + totalPiezas + ' producto(s) al inventario (con precio y ubicación pendientes — complétalos desde el inventario).'); }); // --- Modo genérico de respaldo: una sola tabla plana, se elige a mano cuál columna // trae el monto (para archivos que no traen el formato de "varios meses"). --- function sumaColumnaExcel(colIndex) { let suma = 0; excelFilasActuales.forEach(fila => { const val = fila[colIndex]; if (val === undefined || val === '') return; const num = typeof val === 'number' ? val : parseFloat(String(val).replace(/[^0-9.\-]/g, '')); if (!isNaN(num)) suma += num; }); return suma; } function abrirVistaPreviaExcel(nombreArchivo) { document.getElementById('excelPreviewArchivo').textContent = 'Archivo: ' + nombreArchivo + ' — revisa que la columna y el total sean correctos antes de aplicar.'; document.getElementById('excelConceptoInput').value = 'Compra de mercancía (' + nombreArchivo + ')'; // intenta adivinar cuál columna trae el monto, buscando palabras comunes en el encabezado let colDetectada = excelHeadersActuales.findIndex(h => /total|importe|monto|precio|costo|subtotal/i.test(h)); if (colDetectada === -1) colDetectada = excelHeadersActuales.length - 1; // si no encuentra, asume la última columna const select = document.getElementById('excelColumnaMontoSelect'); select.innerHTML = excelHeadersActuales.map((h, i) => '').join(''); select.value = String(colDetectada); // tabla de vista previa (primeras filas, todas las columnas, para que se revise a ojo) const tabla = document.getElementById('excelPreviewTabla'); let html = ''; excelHeadersActuales.forEach(h => { html += ''; }); html += ''; excelFilasActuales.slice(0, 8).forEach(fila => { html += ''; excelHeadersActuales.forEach((h, i) => { html += ''; }); html += ''; }); html += '
' + escapeHtmlPreview(h) + '
' + escapeHtmlPreview(fila[i] !== undefined ? String(fila[i]) : '') + '
'; if (excelFilasActuales.length > 8) html += '

... y ' + (excelFilasActuales.length - 8) + ' fila(s) más.

'; tabla.innerHTML = html; document.getElementById('excelTotalInput').value = sumaColumnaExcel(colDetectada).toFixed(2); document.getElementById('excelFechaInput').value = new Date().toISOString().slice(0, 10); document.getElementById('excelPreviewModal').style.display = 'flex'; } document.getElementById('excelColumnaMontoSelect').addEventListener('change', (e) => { document.getElementById('excelTotalInput').value = sumaColumnaExcel(parseInt(e.target.value, 10)).toFixed(2); }); document.getElementById('excelPreviewCancelarBtn').addEventListener('click', () => { document.getElementById('excelPreviewModal').style.display = 'none'; }); document.getElementById('excelPreviewAplicarBtn').addEventListener('click', async () => { if (!puedeEditarProveedores) return; if (!proveedorAbiertoId) return; const totalStr = document.getElementById('excelTotalInput').value; const total = parseFloat(String(totalStr).replace(/[^0-9.\-]/g, '')); if (!total || total <= 0) { alertBonito('Revisa el total — tiene que ser un número mayor a cero.'); return; } const concepto = document.getElementById('excelConceptoInput').value.trim(); const fechaStr = document.getElementById('excelFechaInput').value; const fechaIso = fechaStr ? new Date(fechaStr + 'T12:00:00').toISOString() : new Date().toISOString(); const movimientos = await getMovimientosProveedor(); movimientos.push({ id: 'mov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), proveedorId: proveedorAbiertoId, tipo: 'compra', monto: total, concepto, fecha: fechaIso, registradoPor: currentSession ? currentSession.nombre : 'N/D', archivo: document.getElementById('excelPreviewArchivo').textContent.replace('Archivo: ', '').split(' — ')[0], // se guarda el listado tal como se subio (encabezados + filas), para poder verlo o // corregirlo despues sin tener que volver a subir el archivo. listado: { headers: excelHeadersActuales, filas: excelFilasActuales }, }); await setListToStorage(MOVIMIENTOS_PROVEEDOR_KEY, movimientos); document.getElementById('excelPreviewModal').style.display = 'none'; await renderProveedorDetalle(); await renderProveedores(); alertBonito('Compra de ' + money(total) + ' aplicada al saldo del proveedor.'); }); async function showClientesView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.add('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderClientes(''); } async function showAuditoriaView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.add('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderAuditoria(); } async function showCajaView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.add('show'); reportView.classList.remove('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderCajaView(); } async function showReportView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.add('show'); backupView.classList.remove('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await renderWeeklyReport(); } async function showBackupView() { scanView.style.display = 'none'; listView.classList.remove('show'); sellerView.classList.remove('show'); misVentasView.classList.remove('show'); facturasView.classList.remove('show'); carritoView.classList.remove('show'); garantiasView.classList.remove('show'); cotizacionesView.classList.remove('show'); proveedoresView.classList.remove('show'); clientesView.classList.remove('show'); auditoriaView.classList.remove('show'); cajaView.classList.remove('show'); reportView.classList.remove('show'); backupView.classList.add('show'); homeSection.style.display = 'none'; homeBtn.style.display = 'flex'; await refreshBackupStatus(); } async function renderSellerStats() { const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const all = [ ...invSales.filter(Boolean).map(s => ({ vendedor: s.vendedor || 'No especificado', articulo: s.marca + ' ' + s.modelo, precio: Number(s.precio) || 0, comprador: s.comprador || '', metodoPago: s.metodoPago || '', fecha: s.fecha, saleId: s.saleId, tipo: 'inventario', estado: s.estado || 'completada', })), ...extSales.filter(Boolean).map(s => ({ vendedor: s.vendedor || 'No especificado', articulo: s.nombre, precio: Number(s.precio) || 0, comprador: s.comprador || '', metodoPago: s.metodoPago || '', fecha: s.fecha, saleId: s.saleId, tipo: 'externo', estado: s.estado || 'completada', })), ]; const byVendor = {}; const ahoraStats = new Date(); const inicioSemanaStats = getMondayOfWeek(ahoraStats); const inicioMesStats = new Date(ahoraStats.getFullYear(), ahoraStats.getMonth(), 1); all.forEach(s => { if (!byVendor[s.vendedor]) byVendor[s.vendedor] = { count: 0, total: 0, ventas: [], totalSemana: 0, countSemana: 0, totalMes: 0, countMes: 0 }; if (s.estado !== 'cancelada') { byVendor[s.vendedor].count += 1; byVendor[s.vendedor].total += s.precio; const fechaVenta = new Date(s.fecha); if (fechaVenta >= inicioSemanaStats) { byVendor[s.vendedor].totalSemana += s.precio; byVendor[s.vendedor].countSemana += 1; } if (fechaVenta >= inicioMesStats) { byVendor[s.vendedor].totalMes += s.precio; byVendor[s.vendedor].countMes += 1; } } byVendor[s.vendedor].ventas.push(s); }); const sellerTable = document.getElementById('sellerTable'); const sellerCount = document.getElementById('sellerCount'); const entries = Object.entries(byVendor).sort((a, b) => b[1].total - a[1].total); const activeCount = all.filter(s => s && s.estado !== 'cancelada').length; const periodoInicio = await getPeriodoInicio(); let periodoTexto = ''; if (periodoInicio) { const dia = Math.min(PERIODO_DIAS, Math.floor((Date.now() - new Date(periodoInicio).getTime()) / 86400000) + 1); periodoTexto = ' · periodo: día ' + dia + ' de ' + PERIODO_DIAS; } sellerCount.textContent = entries.length + ' vendedor(es) · ' + activeCount + ' ventas en total' + periodoTexto; sellerTable.innerHTML = ''; if (entries.length === 0) { sellerTable.innerHTML = '

Todavía no hay ventas registradas.

'; return; } entries.forEach(([vendedor, stats]) => { const section = document.createElement('div'); section.className = 'seller-section'; const header = document.createElement('div'); header.className = 'seller-header'; header.innerHTML = ` ${vendedor} ${money(stats.total)} · ${stats.count} venta${stats.count === 1 ? '' : 's'}
Esta semana: ${money(stats.totalSemana)} (${stats.countSemana}) Este mes: ${money(stats.totalMes)} (${stats.countMes})
`; section.appendChild(header); const ocultarCanceladas = document.getElementById('ocultarCanceladasToggle').checked; const sortedVentas = stats.ventas .filter(v => !ocultarCanceladas || v.estado !== 'cancelada') .sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); sortedVentas.forEach(v => { const row = document.createElement('div'); const isCancelled = v.estado === 'cancelada'; row.className = 'seller-sale-row' + (isCancelled ? ' is-cancelled' : ''); const fechaStr = v.fecha ? new Date(v.fecha).toLocaleDateString('es-MX') : ''; const cancelBtnHtml = isCancelled ? 'CANCELADA' : ``; const printBtnHtml = ``; row.innerHTML = `
${escapeHtmlCell(v.articulo)} ${fechaStr}${v.comprador ? ' · ' + escapeHtmlCell(v.comprador) : ''}${v.metodoPago ? ' · ' + escapeHtmlCell(v.metodoPago) : ''}
${money(v.precio)}
${printBtnHtml} ${cancelBtnHtml}`; section.appendChild(row); }); sellerTable.appendChild(section); }); } async function renderMisVentas() { const misVentasTable = document.getElementById('misVentasTable'); const misVentasCount = document.getElementById('misVentasCount'); const miNombre = currentSession ? currentSession.nombre : null; if (!miNombre) { misVentasCount.textContent = ''; misVentasTable.innerHTML = '

No hay sesión activa.

'; return; } const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const mias = [ ...invSales.filter(s => s && s.vendedor === miNombre).map(s => ({ articulo: s.marca + ' ' + s.modelo, precio: Number(s.precio) || 0, comprador: s.comprador || '', metodoPago: s.metodoPago || '', fecha: s.fecha, saleId: s.saleId, tipo: 'inventario', estado: s.estado || 'completada', })), ...extSales.filter(s => s && s.vendedor === miNombre).map(s => ({ articulo: s.nombre, precio: Number(s.precio) || 0, comprador: s.comprador || '', metodoPago: s.metodoPago || '', fecha: s.fecha, saleId: s.saleId, tipo: 'externo', estado: s.estado || 'completada', })), ].sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); const activas = mias.filter(s => s && s.estado !== 'cancelada'); const total = activas.reduce((s, v) => s + v.precio, 0); misVentasCount.textContent = miNombre + ' · ' + money(total) + ' · ' + activas.length + ' venta' + (activas.length === 1 ? '' : 's'); misVentasTable.innerHTML = ''; if (mias.length === 0) { misVentasTable.innerHTML = '

Todavia no tienes ventas registradas.

'; return; } mias.forEach(v => { const row = document.createElement('div'); const isCancelled = v.estado === 'cancelada'; row.className = 'seller-sale-row' + (isCancelled ? ' is-cancelled' : ''); const fechaStr = v.fecha ? new Date(v.fecha).toLocaleDateString('es-MX') : ''; const cancelTagHtml = isCancelled ? 'CANCELADA' : ''; const printBtnHtml = ``; row.innerHTML = `
${escapeHtmlCell(v.articulo)} ${fechaStr}${v.comprador ? ' · ' + escapeHtmlCell(v.comprador) : ''}${v.metodoPago ? ' · ' + escapeHtmlCell(v.metodoPago) : ''}
${money(v.precio)}
${printBtnHtml} ${cancelTagHtml}`; misVentasTable.appendChild(row); }); } const GARANTIA_DIAS = 30; const GARANTIA_AVISO_DIAS = 5; // a partir de cuantos dias restantes se marca "por vencer" function calcularEstadoGarantia(fechaVentaIso) { const ahora = new Date(); const fechaVenta = new Date(fechaVentaIso); const diasTranscurridos = Math.floor((ahora - fechaVenta) / 86400000); const diasRestantes = GARANTIA_DIAS - diasTranscurridos; let estado; if (diasRestantes < 0) estado = 'vencida'; else if (diasRestantes <= GARANTIA_AVISO_DIAS) estado = 'por-vencer'; else estado = 'vigente'; return { diasRestantes, estado }; } async function obtenerTodasLasGarantias() { const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const todas = [ ...invSales.filter(s => s && s.estado !== 'cancelada' && s.fecha).map(s => ({ articulo: s.marca + ' ' + s.modelo, comprador: s.comprador || 'Sin nombre', fecha: s.fecha, vendedor: s.vendedor || '', saleId: s.saleId, tipo: 'inventario', })), ...extSales.filter(s => s && s.estado !== 'cancelada' && s.fecha).map(s => ({ articulo: s.nombre, comprador: s.comprador || 'Sin nombre', fecha: s.fecha, vendedor: s.vendedor || '', saleId: s.saleId, tipo: 'externo', })), ].map(s => Object.assign({}, s, calcularEstadoGarantia(s.fecha))); todas.sort((a, b) => a.diasRestantes - b.diasRestantes); return todas; } async function renderGarantias() { const table = document.getElementById('garantiasTable'); const count = document.getElementById('garantiasCount'); const soloPorVencer = document.getElementById('filterSoloGarantiasPorVencer').checked; const texto = (document.getElementById('garantiasSearch').value || '').trim().toLowerCase(); const todas = await obtenerTodasLasGarantias(); let filtradas = soloPorVencer ? todas.filter(g => g.estado !== 'vigente') : todas; if (texto) { filtradas = filtradas.filter(g => (g.saleId || '').toLowerCase().includes(texto) || (g.articulo || '').toLowerCase().includes(texto) || (g.comprador || '').toLowerCase().includes(texto) ); } count.textContent = filtradas.length + ' recibo' + (filtradas.length === 1 ? '' : 's'); table.innerHTML = ''; if (filtradas.length === 0) { table.innerHTML = '

No hay recibos que mostrar.

'; return; } filtradas.forEach(g => { const row = document.createElement('div'); row.className = 'seller-sale-row'; const fechaStr = g.fecha ? new Date(g.fecha).toLocaleDateString('es-MX') : ''; const etiquetaTexto = g.estado === 'vencida' ? 'Vencida (' + Math.abs(g.diasRestantes) + 'd)' : g.estado === 'por-vencer' ? (g.diasRestantes === 0 ? 'Vence hoy' : 'Vence en ' + g.diasRestantes + 'd') : 'Vigente'; const printBtnHtml = g.saleId ? `` : ''; row.title = g.vendedor ? ('Atendió: ' + g.vendedor) : ''; row.innerHTML = `
${escapeHtmlCell(g.articulo)} ${g.saleId ? 'Folio: ' + escapeHtmlCell(g.saleId) + ' · ' : ''}${fechaStr} · ${escapeHtmlCell(g.comprador)}
${etiquetaTexto} ${printBtnHtml}`; table.appendChild(row); }); } document.getElementById('filterSoloGarantiasPorVencer').addEventListener('change', () => { renderGarantias(); }); document.getElementById('garantiasSearch').addEventListener('input', debounce(() => { renderGarantias(); }, 200)); document.getElementById('garantiasBtn').addEventListener('click', showGarantiasView); document.getElementById('garantiasTable').addEventListener('click', async (e) => { const printBtn = e.target.closest('.print-sale-btn'); if (!printBtn) return; const receipt = await buildReceiptFromRecord(printBtn.dataset.saleid, printBtn.dataset.tipo); if (receipt) { lastReceipt = receipt; printReceipt(); } }); // --- Historial por cliente --- async function renderClientes(filterText) { const table = document.getElementById('clientesTable'); const count = document.getElementById('clientesCount'); const q = (filterText || '').trim().toLowerCase(); const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const todas = [ ...invSales.filter(s => s && s.estado !== 'cancelada' && s.comprador).map(s => ({ articulo: s.marca + ' ' + s.modelo, precio: Number(s.precio) || 0, comprador: s.comprador, fecha: s.fecha, vendedor: s.vendedor || '', })), ...extSales.filter(s => s && s.estado !== 'cancelada' && s.comprador).map(s => ({ articulo: s.nombre, precio: Number(s.precio) || 0, comprador: s.comprador, fecha: s.fecha, vendedor: s.vendedor || '', })), ]; const porCliente = {}; todas.forEach(v => { const nombre = v.comprador.trim(); if (!nombre) return; if (!porCliente[nombre]) porCliente[nombre] = { compras: [], total: 0 }; porCliente[nombre].compras.push(v); porCliente[nombre].total += v.precio; }); let entries = Object.entries(porCliente); if (q) entries = entries.filter(([nombre]) => nombre.toLowerCase().includes(q)); entries.sort((a, b) => b[1].total - a[1].total); count.textContent = entries.length + ' cliente' + (entries.length === 1 ? '' : 's'); table.innerHTML = ''; if (entries.length === 0) { table.innerHTML = '

No hay compradores registrados todavía.

'; return; } entries.forEach(([nombre, datos]) => { const row = document.createElement('div'); row.className = 'list-item'; row.innerHTML = `
${escapeHtmlCell(nombre)} ${datos.compras.length} compra${datos.compras.length === 1 ? '' : 's'}
${money(datos.total)}
`; row.onclick = () => showClienteDetalle(nombre, datos); table.appendChild(row); }); } async function renderAuditoria() { const table = document.getElementById('auditoriaTable'); const count = document.getElementById('auditoriaCount'); let log = []; try { log = await getListFromStorage('auditoria'); } catch (e) { log = []; } log = log.filter(Boolean).sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); count.textContent = log.length + ' registro' + (log.length === 1 ? '' : 's'); table.innerHTML = ''; if (log.length === 0) { table.innerHTML = '

Todavía no hay nada registrado.

'; return; } log.forEach(entrada => { const row = document.createElement('div'); row.className = 'seller-sale-row'; const fechaStr = entrada.fecha ? new Date(entrada.fecha).toLocaleString('es-MX') : ''; row.innerHTML = `
${escapeHtmlCell(entrada.accion)} ${fechaStr} · ${escapeHtmlCell(entrada.quien)}
${escapeHtmlCell(entrada.detalle || '')}
`; table.appendChild(row); }); } document.getElementById('auditoriaBtn').addEventListener('click', showAuditoriaView); function showClienteDetalle(nombre, datos) { document.getElementById('clienteDetalleTitle').textContent = nombre; document.getElementById('clienteDetalleResumen').textContent = datos.compras.length + ' compra' + (datos.compras.length === 1 ? '' : 's') + ' · Total: ' + money(datos.total); const cont = document.getElementById('clienteDetalleLista'); cont.innerHTML = ''; const compras = datos.compras.slice().sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); compras.forEach(c => { const garantia = calcularEstadoGarantia(c.fecha); const etiquetaTexto = garantia.estado === 'vencida' ? 'Garantía vencida' : garantia.estado === 'por-vencer' ? (garantia.diasRestantes === 0 ? 'Garantía vence hoy' : 'Garantía: ' + garantia.diasRestantes + ' día' + (garantia.diasRestantes === 1 ? '' : 's')) : 'Garantía vigente (' + garantia.diasRestantes + ' días)'; const fechaStr = c.fecha ? new Date(c.fecha).toLocaleDateString('es-MX') : ''; const row = document.createElement('div'); row.className = 'seller-sale-row'; row.innerHTML = `
${escapeHtmlCell(c.articulo)} ${fechaStr}${c.vendedor ? ' · Atendió: ' + escapeHtmlCell(c.vendedor) : ''}
${money(c.precio)}
${etiquetaTexto}`; cont.appendChild(row); }); document.getElementById('clienteDetalleModal').style.display = 'flex'; } document.getElementById('clienteDetalleCerrarBtn').addEventListener('click', () => { document.getElementById('clienteDetalleModal').style.display = 'none'; }); document.getElementById('clientesBtn').addEventListener('click', showClientesView); document.getElementById('clientesSearch').addEventListener('input', debounce((e) => { renderClientes(e.target.value); }, 200)); async function renderFacturasPendientes() { const facturasTable = document.getElementById('facturasTable'); const facturasCount = document.getElementById('facturasCount'); const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const pendientes = [ ...invSales.filter(s => s && s.facturaSolicitada && !s.facturaEmitida && s.estado !== 'cancelada').map(s => ({ articulo: s.marca + ' ' + s.modelo, precio: Number(s.precio) || 0, comprador: s.comprador || 'Sin nombre', vendedor: s.vendedor || '', fecha: s.fecha, saleId: s.saleId, tipo: 'inventario', })), ...extSales.filter(s => s && s.facturaSolicitada && !s.facturaEmitida && s.estado !== 'cancelada').map(s => ({ articulo: s.nombre, precio: Number(s.precio) || 0, comprador: s.comprador || 'Sin nombre', vendedor: s.vendedor || '', fecha: s.fecha, saleId: s.saleId, tipo: 'externo', })), ].sort((a, b) => new Date(a.fecha) - new Date(b.fecha)); facturasCount.textContent = pendientes.length + ' factura' + (pendientes.length === 1 ? '' : 's') + ' pendiente' + (pendientes.length === 1 ? '' : 's') + ' de emitir'; facturasTable.innerHTML = ''; if (pendientes.length === 0) { facturasTable.innerHTML = '

No hay facturas pendientes por el momento.

'; return; } pendientes.forEach(f => { const row = document.createElement('div'); row.className = 'seller-sale-row'; const fechaStr = f.fecha ? new Date(f.fecha).toLocaleDateString('es-MX') : ''; row.innerHTML = `
${escapeHtmlCell(f.articulo)} ${fechaStr} · ${escapeHtmlCell(f.comprador)}${f.vendedor ? ' · Atendió: ' + escapeHtmlCell(f.vendedor) : ''}
${money(f.precio)}
`; facturasTable.appendChild(row); }); } document.getElementById('facturasTable').addEventListener('click', async (e) => { const btn = e.target.closest('.marcar-factura-btn'); if (!btn) return; const key = btn.dataset.tipo === 'inventario' ? 'ventas_inventario' : 'ventas_externas'; const list = await getSalesWithIds(key); const entry = list.find(s => s && s.saleId === btn.dataset.saleid); if (!entry) return; entry.facturaEmitida = true; entry.facturaEmitidaEn = new Date().toISOString(); await setListToStorage(key, list); await renderFacturasPendientes(); }); document.getElementById('facturasBtn').addEventListener('click', showFacturasView); // --- Carrito: agregar varias laptops (escaneadas o seleccionadas del inventario) a una sola venta --- let carrito = []; function actualizarBadgeCarrito() { const badge = document.getElementById('carritoBadge'); if (carrito.length > 0) { badge.textContent = String(carrito.length); badge.style.display = 'inline-block'; } else { badge.style.display = 'none'; } } function estaEnCarrito(id) { return carrito.some(it => it.id === id); } function precioDe(it) { return it.esManual ? it.precioManual : it.precioBueno; } function agregarAlCarrito(item) { if (soldSet.has(item.id)) { alertBonito(item.id + ' ya está vendido.'); return; } if (estaEnCarrito(item.id)) { alertBonito(item.id + ' ya está en el carrito.'); return; } carrito.push(item); actualizarBadgeCarrito(); } function agregarManualAlCarrito(nombre, precio) { carrito.push({ id: 'manual_' + Date.now() + '_' + Math.floor(Math.random() * 1000), esManual: true, nombre, precioManual: precio, }); actualizarBadgeCarrito(); } function quitarDelCarrito(id) { carrito = carrito.filter(it => it.id !== id); actualizarBadgeCarrito(); renderCarrito(); } function renderCarrito() { const table = document.getElementById('carritoTable'); const count = document.getElementById('carritoCount'); const totalBox = document.getElementById('carritoTotalBox'); const checkoutBtn = document.getElementById('carritoCheckoutBtn'); if (carrito.length === 0) { count.textContent = ''; table.innerHTML = '

Tu carrito está vacío

Escanea, escribe un ID, o agrega un producto con precio manual
'; totalBox.innerHTML = ''; checkoutBtn.style.display = 'none'; return; } checkoutBtn.style.display = 'block'; const total = carrito.reduce((s, it) => s + precioDe(it), 0); count.textContent = carrito.length + ' producto' + (carrito.length === 1 ? '' : 's') + ' en el carrito'; table.innerHTML = ''; carrito.forEach(it => { const row = document.createElement('div'); row.className = 'venta-item-row'; row.innerHTML = `
${escapeHtmlCell(it.esManual ? it.nombre : (it.marca + ' ' + it.modelo))} ${it.esManual ? 'Precio manual' : escapeHtmlCell(it.id)}
${money(precioDe(it))} `; table.appendChild(row); }); totalBox.innerHTML = '
Subtotal (' + carrito.length + ' producto' + (carrito.length === 1 ? '' : 's') + ')' + money(total) + '
' + '
Total a cobrar' + money(total) + '
'; } document.getElementById('carritoTable').addEventListener('click', (e) => { const btn = e.target.closest('.venta-item-quitar'); if (!btn) return; quitarDelCarrito(btn.dataset.id); }); document.getElementById('carritoIdInput').addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); const input = document.getElementById('carritoIdInput'); const idRaw = input.value.trim().toUpperCase(); if (!idRaw) return; const id = INDEX[idRaw] ? idRaw : normalizarCodigoEscaneado(idRaw); const item = INDEX[id]; if (!item) { alertBonito('No se encontró ningún producto con el ID "' + idRaw + '".'); return; } agregarAlCarrito(item); renderCarrito(); input.value = ''; }); document.getElementById('carritoAgregarManualBtn').addEventListener('click', () => { document.getElementById('carritoManualNombreInput').value = ''; document.getElementById('carritoManualPrecioInput').value = ''; document.getElementById('carritoManualForm').style.display = 'block'; document.getElementById('carritoManualNombreInput').focus(); }); document.getElementById('carritoManualCancelarBtn').addEventListener('click', () => { document.getElementById('carritoManualForm').style.display = 'none'; }); document.getElementById('carritoManualAgregarBtn').addEventListener('click', () => { const nombre = document.getElementById('carritoManualNombreInput').value.trim(); if (!nombre) { alertBonito('Escribe el nombre del producto.'); return; } const precio = parseFloat((document.getElementById('carritoManualPrecioInput').value || '').replace(/[^0-9.]/g, '')) || 0; if (precio <= 0) { alertBonito('Escribe un precio válido.'); return; } agregarManualAlCarrito(nombre, precio); document.getElementById('carritoManualForm').style.display = 'none'; renderCarrito(); }); document.getElementById('carritoBtn').addEventListener('click', showCarritoView); document.getElementById('carritoVaciarBtn').addEventListener('click', async () => { if (carrito.length === 0) return; if (!(await confirmarBonito('Se perderán todos los productos agregados.', '¿Vaciar todo el carrito?'))) return; carrito = []; actualizarBadgeCarrito(); renderCarrito(); }); function generarTicketCarrito(items, total, comprador, vendedor, pago) { const now = new Date(); const fecha = now.toLocaleDateString('es-MX') + ' ' + now.toLocaleTimeString('es-MX'); const folio = 'Folio: CARRITO-' + now.getTime().toString().slice(-6); const filenameBase = 'Ticket_Carrito_' + now.toISOString().slice(0, 10); const lineasRecibo = items.map(it => it.marca + ' ' + it.modelo + ' (' + it.id + ') — ' + money(it.precioBueno)); setLastReceipt({ folio, fecha, articulo: items.length + ' artículo(s)', lineas: lineasRecibo, precio: total, pago, comprador, vendedor, }); if (typeof window.jspdf === 'undefined') { const lineas = [ STORE_NAME, STORE_ADDRESS, 'Tel. ' + STORE_PHONE, 'TICKET DE VENTA', folio, 'Fecha y hora: ' + fecha, '', ...lineasRecibo, '', 'Total: ' + money(total), 'Método de pago: ' + pago.metodo, pago.metodo === 'Efectivo' ? ('Recibido: ' + money(pago.recibido)) : '', pago.metodo === 'Efectivo' ? ('Cambio: ' + money(pago.cambio)) : '', comprador ? ('Comprador: ' + comprador) : '', 'Vendedor: ' + vendedor, '', 'Garantía de 30 días.', 'No hay reembolso de dinero, solo cambio de mercancía.', '', 'Para factura: manda WhatsApp al 000-000-0000', 'con foto del ticket y tu constancia de', 'situación fiscal (o tus datos fiscales).', ].filter(Boolean); downloadTextFallback(filenameBase + '.txt', lineas.join('\n')); return; } try { const { jsPDF } = window.jspdf; const doc = new jsPDF({ unit: 'mm', format: getTipoImpresora() === 'normal' ? 'letter' : [80, 230] }); let y = 8; try { const logoW = 40, logoH = logoW * 157 / 400; doc.addImage('data:image/png;base64,' + LOGO_B64, 'PNG', 40 - logoW / 2, y, logoW, logoH); y += logoH + 4; } catch (e) {} doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.text(STORE_NAME, 40, y, { align: 'center' }); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.text(STORE_ADDRESS, 40, y, { align: 'center', maxWidth: 68 }); y += 3.2; doc.text('Tel. ' + STORE_PHONE, 40, y, { align: 'center' }); y += 4; doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.text('TICKET DE VENTA', 40, y, { align: 'center' }); y += 5; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.text(folio, 40, y, { align: 'center' }); y += 4; doc.text('Fecha y hora: ' + fecha, 40, y, { align: 'center' }); y += 6; doc.setLineWidth(0.2); doc.line(6, y, 74, y); y += 5; doc.setFont('helvetica', 'bold'); doc.setFontSize(9); items.forEach(it => { const nombreLines = doc.splitTextToSize(it.marca + ' ' + it.modelo + ' (' + it.id + ')', 68); nombreLines.forEach(l => { doc.text(l, 6, y); y += 4; }); doc.setFont('helvetica', 'normal'); doc.text(money(it.precioBueno), 74, y - 4, { align: 'right' }); doc.setFont('helvetica', 'bold'); y += 1; }); y += 3; doc.setLineWidth(0.2); doc.line(6, y, 74, y); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.text('Total: ' + money(total), 40, y, { align: 'center' }); y += 7; doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.text('Método de pago: ' + pago.metodo, 6, y); y += 4; if (pago.metodo === 'Efectivo') { doc.text('Recibido: ' + money(pago.recibido), 6, y); y += 4; doc.text('Cambio: ' + money(pago.cambio), 6, y); y += 4; } if (comprador) { doc.text('Comprador: ' + comprador, 6, y); y += 4; } doc.text('Vendedor: ' + vendedor, 6, y); y += 6; y = agregarFirmaAlPdf(doc, y, pago.firma); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); doc.text('Garantía de 30 días.', 40, y, { align: 'center' }); y += 4; doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.text('No hay reembolso de dinero, solo cambio de mercancía.', 40, y, { align: 'center', maxWidth: 68 }); y += 6; doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.text('¿Necesitas factura?', 40, y, { align: 'center' }); y += 3.8; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.text('WhatsApp 000-000-0000 con foto del ticket y', 40, y, { align: 'center', maxWidth: 68 }); y += 3.5; doc.text('tu constancia de situación fiscal (o tus datos).', 40, y, { align: 'center', maxWidth: 68 }); mostrarPreviaImpresion(doc, 'Vista previa del recibo', filenameBase + '.pdf'); } catch (e) { downloadTextFallback(filenameBase + '.txt', [STORE_NAME, folio, ...lineasRecibo].join('\n')); } } document.getElementById('carritoCheckoutBtn').addEventListener('click', async () => { if (carrito.length === 0) return; const checkoutBtn = document.getElementById('carritoCheckoutBtn'); if (checkoutBtn.disabled) return; // ya se está procesando este mismo clic, evita doble venta por doble toque checkoutBtn.disabled = true; try { const disponibles = carrito.filter(it => !it.esManual && !soldSet.has(it.id)); const manuales = carrito.filter(it => it.esManual); if (disponibles.length === 0 && manuales.length === 0) { alertBonito('Todos los productos del carrito ya están vendidos.'); carrito = []; actualizarBadgeCarrito(); renderCarrito(); return; } const total = disponibles.reduce((s, it) => s + it.precioBueno, 0) + manuales.reduce((s, it) => s + it.precioManual, 0); const detalles = await pedirDetallesVenta(total); if (!detalles) return; const { comprador, facturaSolicitada, ...pago } = detalles; const vendedor = currentSession ? currentSession.nombre : 'No especificado'; const fecha = new Date().toISOString(); disponibles.forEach(it => soldSet.add(it.id)); await saveSoldSet(); const saleIdsInventario = []; for (const item of disponibles) { const saleIdItem = await logInventorySale({ id: item.id, marca: item.marca, modelo: item.modelo, precio: item.precioBueno, comprador, vendedor, fecha, metodoPago: pago.metodo, recibido: item.precioBueno, cambio: 0, }, facturaSolicitada); saleIdsInventario.push(saleIdItem); } const saleIdsExternas = []; if (manuales.length > 0) { const listaExternas = await getListFromStorage('ventas_externas'); manuales.forEach(it => { const entry = { nombre: it.nombre, caracteristicas: '', precio: it.precioManual, comprador, vendedor, fecha, metodoPago: pago.metodo, recibido: it.precioManual, cambio: 0, saleId: newSaleId(), facturaSolicitada, facturaEmitida: false, }; listaExternas.push(entry); saleIdsExternas.push(entry.saleId); }); await setListToStorage('ventas_externas', listaExternas); } // se guardan los dos tipos de saleId por separado para que "deshacer" sepa // cancelar cada uno con la funcion correcta (inventario vs externa/manual) registrarUltimaAccion({ tipo: 'venta_carrito', saleIdsInventario, saleIdsExternas, descripcion: 'venta del carrito (' + (disponibles.length + manuales.length) + ' producto(s))', }); const articulosDesc = disponibles.map(it => it.marca + ' ' + it.modelo).concat(manuales.map(it => it.nombre)).join(', '); await registrarVentaEnCaja(articulosDesc, pago); const itemsParaTicket = disponibles.concat(manuales.map(it => ({ id: 'MANUAL', marca: it.nombre, modelo: '', precioBueno: it.precioManual }))); generarTicketCarrito(itemsParaTicket, total, comprador, vendedor, pago); reproducirSonido('venta'); carrito = []; actualizarBadgeCarrito(); updateLoadedCount(); renderCarrito(); } finally { checkoutBtn.disabled = false; } }); async function renderCajaView() { const estado = await getCajaEstado(); const closedBox = document.getElementById('cajaClosedBox'); const openBox = document.getElementById('cajaOpenBox'); if (!estado.abierta) { closedBox.style.display = 'block'; openBox.style.display = 'none'; return; } closedBox.style.display = 'none'; openBox.style.display = 'block'; const movimientos = await getCajaMovimientos(); const saldo = cajaBalance(estado.fondoInicial, movimientos); document.getElementById('cajaSaldo').textContent = money(saldo); const apertura = estado.fechaApertura ? new Date(estado.fechaApertura).toLocaleString('es-MX') : ''; document.getElementById('cajaFondoInfo').textContent = 'Fondo inicial ' + money(estado.fondoInicial) + ' · abierta ' + apertura; const list = document.getElementById('cajaMovList'); list.innerHTML = ''; if (movimientos.length === 0) { list.innerHTML = '

Sin movimientos todavía.

'; return; } movimientos.slice().reverse().forEach(m => { const row = document.createElement('div'); row.className = 'caja-mov-row'; const fechaStr = new Date(m.fecha).toLocaleString('es-MX'); const signo = m.tipo === 'entrada' ? '+' : '-'; row.innerHTML = `
${escapeHtmlCell(m.concepto)} ${fechaStr}
${signo}${money(m.monto)}`; list.appendChild(row); }); } // --- Reporte semanal (solo administrador) --- let reportWeekOffset = 0; let reportMonthOffset = 0; let reportPeriodMode = 'semana'; let reportDailyChartInstance = null; let reportItemsChartInstance = null; let lastReportData = null; function getMondayOfWeek(date) { const d = new Date(date); const day = d.getDay(); // 0=domingo, 1=lunes, ... 6=sabado const diff = (day === 0 ? -6 : 1) - day; d.setDate(d.getDate() + diff); d.setHours(0, 0, 0, 0); return d; } function formatShortDate(d) { return d.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }); } function getReportRange() { const today = new Date(); if (reportPeriodMode === 'semana') { const start = getMondayOfWeek(today); start.setDate(start.getDate() + reportWeekOffset * 7); const end = new Date(start); end.setDate(end.getDate() + 5); end.setHours(23, 59, 59, 999); return { start, end, label: (reportWeekOffset === 0 ? 'Esta semana: ' : '') + formatShortDate(start) + ' - ' + formatShortDate(end) }; } else { const base = new Date(today.getFullYear(), today.getMonth() + reportMonthOffset, 1); const start = new Date(base.getFullYear(), base.getMonth(), 1, 0, 0, 0, 0); const end = new Date(base.getFullYear(), base.getMonth() + 1, 0, 23, 59, 59, 999); const label = base.toLocaleDateString('es-MX', { month: 'long', year: 'numeric' }); return { start, end, label: label.charAt(0).toUpperCase() + label.slice(1) }; } } // --- Gastos de la semana/mes --- async function getGastos() { return await getListFromStorage('gastos'); } async function addGasto(concepto, monto) { const list = await getGastos(); list.push({ id: newSaleId(), concepto, monto, fecha: new Date().toISOString() }); await setListToStorage('gastos', list); } async function removeGasto(id) { const list = await getGastos(); await setListToStorage('gastos', list.filter(g => g.id !== id)); } document.getElementById('agregarGastoBtn').addEventListener('click', async () => { const concepto = await pedirTexto('¿En qué se gastó?', '', { placeholder: 'ej. renta, gasolina, papelería' }); if (!concepto) return; const montoStr = await pedirTexto('Monto del gasto (MXN)', '', { numerico: true }); if (montoStr === null) return; const monto = parseFloat(montoStr.replace(/[^0-9.]/g, '')) || 0; if (monto <= 0) return; await addGasto(concepto, monto); await renderWeeklyReport(); }); document.getElementById('gastosList').addEventListener('click', async (e) => { const btn = e.target.closest('.gasto-del-btn'); if (!btn) return; if (!(await confirmarBonito('Esto no se puede deshacer.', '¿Eliminar este gasto?'))) return; await removeGasto(btn.dataset.id); await renderWeeklyReport(); }); async function getAllActiveSalesForReport() { const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const all = [ ...invSales.filter(Boolean).map(s => ({ fecha: s.fecha, precio: Number(s.precio) || 0, articulo: s.marca + ' ' + s.modelo, estado: s.estado || 'completada', categoria: (INDEX[(s.id || '').toUpperCase()] && INDEX[(s.id || '').toUpperCase()].categoria) || 'Laptops' })), ...extSales.filter(Boolean).map(s => ({ fecha: s.fecha, precio: Number(s.precio) || 0, articulo: s.nombre, estado: s.estado || 'completada', categoria: 'Externa' })), ]; return all.filter(s => s && s.estado !== 'cancelada' && s.fecha); } async function renderWeeklyReport() { const allSales = await getAllActiveSalesForReport(); const { start, end, label } = getReportRange(); document.getElementById('reportWeekLabel').textContent = label; const periodSales = allSales.filter(s => { const d = new Date(s.fecha); return d >= start && d <= end; }); let bucketLabels, bucketTotals, bucketCounts, bestLabel; if (reportPeriodMode === 'semana') { bucketLabels = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; bucketTotals = [0, 0, 0, 0, 0, 0]; bucketCounts = [0, 0, 0, 0, 0, 0]; periodSales.forEach(s => { const d = new Date(s.fecha); const idx = (d.getDay() + 6) % 7; // lunes=0 ... domingo=6 if (idx <= 5) { bucketTotals[idx] += s.precio; bucketCounts[idx] += 1; } }); document.getElementById('reportBestDayLabel').textContent = 'Mejor día'; document.getElementById('reportDailyChartTitle').textContent = 'Ventas por día (lunes a sábado)'; } else { const numWeeks = Math.ceil((end.getDate()) / 7); bucketLabels = Array.from({ length: numWeeks }, (_, i) => 'Semana ' + (i + 1)); bucketTotals = new Array(numWeeks).fill(0); bucketCounts = new Array(numWeeks).fill(0); periodSales.forEach(s => { const d = new Date(s.fecha); const idx = Math.floor((d.getDate() - 1) / 7); if (idx < numWeeks) { bucketTotals[idx] += s.precio; bucketCounts[idx] += 1; } }); document.getElementById('reportBestDayLabel').textContent = 'Mejor semana'; document.getElementById('reportDailyChartTitle').textContent = 'Ventas por semana del mes'; } const totalVendido = periodSales.reduce((sum, s) => sum + s.precio, 0); document.getElementById('reportTotal').textContent = money(totalVendido); document.getElementById('reportCount').textContent = periodSales.length; let bestIdx = -1, bestVal = 0; bucketTotals.forEach((v, i) => { if (v > bestVal) { bestVal = v; bestIdx = i; } }); bestLabel = bestIdx >= 0 ? bucketLabels[bestIdx] : '-'; document.getElementById('reportBestDay').textContent = bestLabel; document.getElementById('reportEmptyMsg').style.display = periodSales.length === 0 ? 'block' : 'none'; // gastos del periodo const allGastos = await getGastos(); const periodGastos = allGastos.filter(g => { if (!g || !g.fecha) return false; const d = new Date(g.fecha); return d >= start && d <= end; }).sort((a, b) => new Date(b.fecha) - new Date(a.fecha)); const totalGastos = periodGastos.reduce((sum, g) => sum + (Number(g.monto) || 0), 0); const ganancia = totalVendido - totalGastos; document.getElementById('reportGastos').textContent = money(totalGastos); const gananciaEl = document.getElementById('reportGanancia'); gananciaEl.textContent = money(ganancia); gananciaEl.classList.toggle('gasto-neg', ganancia < 0); gananciaEl.classList.toggle('ganancia-pos', ganancia >= 0); const gastosListEl = document.getElementById('gastosList'); gastosListEl.innerHTML = ''; if (periodGastos.length === 0) { gastosListEl.innerHTML = '

Sin gastos registrados en este periodo.

'; } else { periodGastos.forEach(g => { const row = document.createElement('div'); row.className = 'gasto-row'; const fechaStr = new Date(g.fecha).toLocaleDateString('es-MX'); row.innerHTML = `
${escapeHtmlCell(g.concepto)} ${fechaStr}
-${money(g.monto)} `; gastosListEl.appendChild(row); }); } // articulos mas vendidos (por dinero) const itemTotals = {}; periodSales.forEach(s => { if (!itemTotals[s.articulo]) itemTotals[s.articulo] = { count: 0, total: 0 }; itemTotals[s.articulo].count += 1; itemTotals[s.articulo].total += s.precio; }); const topItems = Object.entries(itemTotals).sort((a, b) => b[1].total - a[1].total).slice(0, 8); // ventas por categoria const catTotals = {}; periodSales.forEach(s => { const cat = s.categoria || 'Sin categoría'; if (!catTotals[cat]) catTotals[cat] = { count: 0, total: 0 }; catTotals[cat].count += 1; catTotals[cat].total += s.precio; }); const catEntries = Object.entries(catTotals).sort((a, b) => b[1].total - a[1].total); const catMax = catEntries.length ? catEntries[0][1].total : 0; const catCont = document.getElementById('reportCategoriasLista'); if (catEntries.length === 0) { catCont.innerHTML = '

Sin ventas registradas en este periodo.

'; } else { catCont.innerHTML = catEntries.map(([cat, v]) => { const pct = catMax > 0 ? Math.round((v.total / catMax) * 100) : 0; return `
${cat} ${money(v.total)}
${v.count} venta${v.count === 1 ? '' : 's'}
`; }).join(''); } // guardamos todo para poder descargarlo tal cual se esta viendo lastReportData = { label, bucketLabels, bucketTotals, bucketCounts, totalVendido, totalGastos, ganancia, count: periodSales.length, topItems, catEntries, gastos: periodGastos, mode: reportPeriodMode }; if (typeof Chart === 'undefined') return; // sin internet la primera vez, no se pueden dibujar graficas if (reportDailyChartInstance) reportDailyChartInstance.destroy(); if (reportItemsChartInstance) reportItemsChartInstance.destroy(); const chartTextColor = '#a7b0c4'; const chartGridColor = 'rgba(167,176,196,0.12)'; reportDailyChartInstance = new Chart(document.getElementById('reportDailyChart'), { type: 'bar', data: { labels: bucketLabels, datasets: [{ label: 'Vendido (MXN)', data: bucketTotals, backgroundColor: '#35c3f0', borderRadius: 6, }], }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: chartTextColor }, grid: { color: chartGridColor } }, y: { ticks: { color: chartTextColor, callback: v => '$' + v.toLocaleString('es-MX') }, grid: { color: chartGridColor } }, }, }, }); reportItemsChartInstance = new Chart(document.getElementById('reportItemsChart'), { type: 'bar', data: { labels: topItems.map(([name]) => name.length > 22 ? name.slice(0, 22) + '…' : name), datasets: [{ label: 'Unidades vendidas', data: topItems.map(([, v]) => v.count), backgroundColor: '#ffb454', borderRadius: 6, }], }, options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: chartTextColor, stepSize: 1 }, grid: { color: chartGridColor } }, y: { ticks: { color: chartTextColor }, grid: { display: false } }, }, }, }); } document.getElementById('reportPrevWeek').addEventListener('click', async () => { if (reportPeriodMode === 'semana') reportWeekOffset -= 1; else reportMonthOffset -= 1; await renderWeeklyReport(); }); document.getElementById('reportNextWeek').addEventListener('click', async () => { if (reportPeriodMode === 'semana') { if (reportWeekOffset >= 0) return; // no adelantarse a semanas futuras reportWeekOffset += 1; } else { if (reportMonthOffset >= 0) return; // no adelantarse a meses futuros reportMonthOffset += 1; } await renderWeeklyReport(); }); document.getElementById('periodSemanaBtn').addEventListener('click', async () => { reportPeriodMode = 'semana'; document.getElementById('periodSemanaBtn').classList.add('active'); document.getElementById('periodMesBtn').classList.remove('active'); await renderWeeklyReport(); }); document.getElementById('periodMesBtn').addEventListener('click', async () => { reportPeriodMode = 'mes'; document.getElementById('periodMesBtn').classList.add('active'); document.getElementById('periodSemanaBtn').classList.remove('active'); await renderWeeklyReport(); }); document.getElementById('downloadReportBtn').addEventListener('click', () => { if (!lastReportData) return; const r = lastReportData; const rows = []; rows.push(['Reporte de ventas - ' + STORE_NAME]); rows.push(['Período', r.label]); rows.push([]); rows.push(['Total vendido', money(r.totalVendido)]); rows.push(['Número de ventas', r.count]); rows.push(['Total gastos', money(r.totalGastos)]); rows.push(['Ganancia neta', money(r.ganancia)]); rows.push([]); rows.push([r.mode === 'semana' ? 'Ventas por dia' : 'Ventas por semana']); rows.push(['Período', 'Total vendido', 'Número de ventas']); r.bucketLabels.forEach((lbl, i) => rows.push([lbl, r.bucketTotals[i], r.bucketCounts[i]])); rows.push([]); rows.push(['Artículos más vendidos']); rows.push(['Artículo', 'Unidades', 'Total vendido']); r.topItems.forEach(([name, v]) => rows.push([name, v.count, v.total])); rows.push([]); rows.push(['Ventas por categoría']); rows.push(['Categoría', 'Número de ventas', 'Total vendido']); (r.catEntries || []).forEach(([cat, v]) => rows.push([cat, v.count, v.total])); rows.push([]); rows.push(['Gastos del período']); rows.push(['Concepto', 'Monto', 'Fecha']); (r.gastos || []).forEach(g => { if (g) rows.push([g.concepto, g.monto, g.fecha ? new Date(g.fecha).toLocaleDateString('es-MX') : '']); }); const csv = '\uFEFF' + rows.map(row => row.map(csvEscape).join(',')).join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const today = new Date().toISOString().slice(0, 10); a.href = url; a.download = 'Reporte_' + r.mode + '_' + today + '.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }); document.getElementById('abrirCajaBtn').addEventListener('click', async () => { const fondoStr = await pedirTexto('Fondo inicial de caja (MXN)', '0', { numerico: true }); if (fondoStr === null) return; const fondoInicial = parseFloat(fondoStr.replace(/[^0-9.]/g, '')) || 0; await setCajaEstado({ abierta: true, fondoInicial, fechaApertura: new Date().toISOString() }); await setListToStorage('caja_movimientos', []); registrarAuditoria('Abrió caja', 'Fondo inicial: ' + money(fondoInicial)); await renderCajaView(); }); document.getElementById('entradaBtn').addEventListener('click', async () => { const concepto = (await pedirTexto('Concepto de la entrada de dinero', '')) || 'Entrada de dinero'; const montoStr = await pedirTexto('Monto (MXN)', '', { numerico: true }); if (montoStr === null) return; const monto = parseFloat(montoStr.replace(/[^0-9.]/g, '')) || 0; if (monto <= 0) return; await addCajaMovimiento('entrada', concepto, monto); await renderCajaView(); }); document.getElementById('salidaBtn').addEventListener('click', async () => { const concepto = (await pedirTexto('Concepto de la salida de dinero', '')) || 'Salida de dinero'; const montoStr = await pedirTexto('Monto (MXN)', '', { numerico: true }); if (montoStr === null) return; const monto = parseFloat(montoStr.replace(/[^0-9.]/g, '')) || 0; if (monto <= 0) return; await addCajaMovimiento('salida', concepto, monto); await renderCajaView(); }); document.getElementById('cerrarCajaBtn').addEventListener('click', async () => { const estado = await getCajaEstado(); const movimientos = await getCajaMovimientos(); const totalEntradas = movimientos.filter(m => m.tipo === 'entrada').reduce((s, m) => s + m.monto, 0); const totalSalidas = movimientos.filter(m => m.tipo === 'salida').reduce((s, m) => s + m.monto, 0); const saldoFinal = cajaBalance(estado.fondoInicial, movimientos); const resumen = 'Fondo inicial: ' + money(estado.fondoInicial) + '\nTotal entradas: ' + money(totalEntradas) + '\nTotal salidas: ' + money(totalSalidas) + '\nSaldo final: ' + money(saldoFinal); if (!(await confirmarBonito(resumen, '¿Cerrar la caja con este resumen?'))) return; const historial = await getListFromStorage('caja_historial'); historial.push({ fechaApertura: estado.fechaApertura, fechaCierre: new Date().toISOString(), fondoInicial: estado.fondoInicial, totalEntradas, totalSalidas, saldoFinal, movimientos, }); await setListToStorage('caja_historial', historial); const headers = ['Tipo', 'Concepto', 'Monto', 'Fecha']; const rows = movimientos.filter(Boolean).map(m => [m.tipo === 'entrada' ? 'Entrada' : 'Salida', m.concepto, m.monto, m.fecha ? new Date(m.fecha).toLocaleString('es-MX') : '']); rows.push(['', 'FONDO INICIAL', estado.fondoInicial, '']); rows.push(['', 'SALDO FINAL', saldoFinal, '']); downloadCSV('Corte_caja_' + new Date().toISOString().slice(0,10) + '.csv', headers, rows); await setCajaEstado({ abierta: false }); await setListToStorage('caja_movimientos', []); registrarAuditoria('Cerró caja', 'Saldo final: ' + money(saldoFinal)); await renderCajaView(); }); async function buildReceiptFromRecord(saleId, tipo) { if (tipo === 'inventario') { const list = await getSalesWithIds('ventas_inventario'); const r = list.find(e => e && e.saleId === saleId); if (!r) return null; const item = INDEX[r.id]; const lineas = item ? [ 'ID: ' + item.id, 'Ubicación: Anaquel ' + item.anaquel + ' / Nivel ' + item.nivel, 'Procesador: ' + (item.procesador || 'N/D'), 'RAM: ' + (item.ram || 'N/D') + ' Almac.: ' + (item.almacenamiento || 'N/D'), ].concat(item.touch ? ['Pantalla: ' + item.touch] : []).concat(item.gpu ? ['Gráfica: ' + item.gpu] : []) : ['ID: ' + r.id]; return { folio: 'Folio: ' + r.saleId, fecha: r.fecha ? new Date(r.fecha).toLocaleDateString('es-MX') + ' ' + new Date(r.fecha).toLocaleTimeString('es-MX') : '', articulo: r.marca + ' ' + r.modelo, lineas, precio: r.precio, pago: { metodo: r.metodoPago || 'N/D', recibido: r.recibido, cambio: r.cambio }, comprador: r.comprador, vendedor: r.vendedor, }; } else { const list = await getSalesWithIds('ventas_externas'); const r = list.find(e => e && e.saleId === saleId); if (!r) return null; return { folio: 'Folio: ' + r.saleId, fecha: r.fecha ? new Date(r.fecha).toLocaleDateString('es-MX') + ' ' + new Date(r.fecha).toLocaleTimeString('es-MX') : '', articulo: r.nombre, lineas: r.caracteristicas ? ['Características: ' + r.caracteristicas] : [], precio: r.precio, pago: { metodo: r.metodoPago || 'N/D', recibido: r.recibido, cambio: r.cambio }, comprador: r.comprador, vendedor: r.vendedor, }; } } document.getElementById('sellerTable').addEventListener('click', async (e) => { const printBtn = e.target.closest('.print-sale-btn'); if (printBtn) { const receipt = await buildReceiptFromRecord(printBtn.dataset.saleid, printBtn.dataset.tipo); if (receipt) { lastReceipt = receipt; printReceipt(); } return; } const btn = e.target.closest('.cancel-sale-btn'); if (!btn) return; const saleId = btn.dataset.saleid; const tipo = btn.dataset.tipo; const ok = tipo === 'inventario' ? await cancelarVentaInventario(saleId) : await cancelarVentaExterna(saleId); if (ok) await renderSellerStats(); }); document.getElementById('misVentasTable').addEventListener('click', async (e) => { const printBtn = e.target.closest('.print-sale-btn'); if (!printBtn) return; const receipt = await buildReceiptFromRecord(printBtn.dataset.saleid, printBtn.dataset.tipo); if (receipt) { lastReceipt = receipt; printReceipt(); } }); viewListBtn.addEventListener('click', showListView); sellerStatsBtn.addEventListener('click', showSellerView); document.getElementById('deshacerBtn').addEventListener('click', deshacerUltimaAccion); // Muestra un modal bonito (en vez del alert() feo del navegador, que en celular // suele verse como una alerta a pantalla completa sin formato) para avisos cortos. function mostrarInfoModal(opciones) { const { titulo, parrafos, detalleTecnico, tipo } = opciones; document.getElementById('infoModalTitulo').textContent = titulo || 'Aviso'; const icono = document.getElementById('infoModalIcono'); icono.className = 'info-modal-icono' + (tipo === 'error' ? ' error' : ''); icono.innerHTML = ''; const cuerpo = document.getElementById('infoModalCuerpo'); let html = (parrafos || []).map(p => '

' + p + '

').join(''); if (detalleTecnico) html += '
' + detalleTecnico + '
'; cuerpo.innerHTML = html; document.getElementById('infoModal').style.display = 'flex'; } document.getElementById('infoModalAceptarBtn').addEventListener('click', () => { document.getElementById('infoModal').style.display = 'none'; }); // Reemplazo directo de alertBonito(mensaje) — mismo uso, pero con el modal bonito // en vez de la alerta nativa del navegador (que dice "Esta página dice" y se ve fea). // Sonido corto de notificación (sintetizado, sin necesitar ningún archivo de audio). // tipo: 'venta' (dos tonos alegres), 'chat' (dos tonos suaves), 'error' (un tono grave), // o cualquier otra cosa para un aviso normal (un tono neutro). function reproducirSonido(tipo) { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const ahora = ctx.currentTime; function tono(freq, inicio, duracion, volumen) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0.0001, ahora + inicio); gain.gain.exponentialRampToValueAtTime(volumen, ahora + inicio + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, ahora + inicio + duracion); osc.start(ahora + inicio); osc.stop(ahora + inicio + duracion + 0.03); } if (tipo === 'venta') { tono(880, 0, 0.12, 0.22); tono(1318, 0.1, 0.18, 0.22); } else if (tipo === 'chat') { tono(660, 0, 0.1, 0.16); tono(880, 0.09, 0.14, 0.16); } else if (tipo === 'error') { tono(330, 0, 0.2, 0.18); } else { tono(740, 0, 0.14, 0.16); } setTimeout(() => { try { ctx.close(); } catch (e) {} }, 700); } catch (e) { /* si el navegador todavia no permite audio (sin interaccion previa), no truena nada */ } } function alertBonito(mensaje) { const texto = String(mensaje); const esError = /no se pudo|no se pudieron|ocurrió un error|no se encontr|no válid|inválid|escribe|agrega al menos|marca al menos|ya est|ya existe|no hay ning|solo un administrador|falló/i.test(texto); reproducirSonido(esError ? 'error' : 'aviso'); const parrafos = texto.split(/\n\n+/).map(t => t.trim()).filter(Boolean).map(t => t.replace(/\n/g, '
')); mostrarInfoModal({ titulo: esError ? 'Atención' : 'Aviso', parrafos: parrafos.length ? parrafos : [texto], tipo: esError ? 'error' : 'ok', }); } // Reemplazo de window.confirm(mensaje) por un modal bonito. Devuelve una Promise // que resuelve true si el usuario confirma, false si cancela. function confirmarBonito(mensaje, titulo) { return new Promise((resolve) => { const modal = document.getElementById('confirmBonitoModal'); const aceptarBtn = document.getElementById('confirmBonitoAceptarBtn'); const cancelarBtn = document.getElementById('confirmBonitoCancelarBtn'); document.getElementById('confirmBonitoTitulo').textContent = titulo || '¿Confirmar?'; const texto = String(mensaje); const parrafos = texto.split(/\n\n+/).map(t => t.trim()).filter(Boolean).map(t => t.replace(/\n/g, '
')); document.getElementById('confirmBonitoCuerpo').innerHTML = (parrafos.length ? parrafos : [texto]).map(p => '

' + p + '

').join(''); function limpiar() { modal.style.display = 'none'; aceptarBtn.removeEventListener('click', onAceptar); cancelarBtn.removeEventListener('click', onCancelar); } function onAceptar() { limpiar(); resolve(true); } function onCancelar() { limpiar(); resolve(false); } aceptarBtn.addEventListener('click', onAceptar); cancelarBtn.addEventListener('click', onCancelar); modal.style.display = 'flex'; }); } // Reemplazo de window.prompt(mensaje, valorPorDefecto) por un modal bonito. Devuelve una // Promise que resuelve con el texto escrito, o null si se cancela. // opciones.numerico=true pone teclado numérico en celular (inputmode) y alinea el texto // como los demás campos de dinero de la app. function pedirTexto(titulo, valorPorDefecto, opciones) { opciones = opciones || {}; return new Promise((resolve) => { const modal = document.getElementById('pedirTextoModal'); const input = document.getElementById('pedirTextoInput'); const hintEl = document.getElementById('pedirTextoHint'); const aceptarBtn = document.getElementById('pedirTextoAceptarBtn'); const cancelarBtn = document.getElementById('pedirTextoCancelarBtn'); document.getElementById('pedirTextoTitulo').textContent = titulo || 'Escribe un valor'; if (opciones.hint) { hintEl.textContent = opciones.hint; hintEl.style.display = ''; } else { hintEl.style.display = 'none'; } input.value = (valorPorDefecto === undefined || valorPorDefecto === null) ? '' : String(valorPorDefecto); input.setAttribute('inputmode', opciones.numerico ? 'decimal' : 'text'); input.placeholder = opciones.placeholder || ''; function limpiar() { modal.style.display = 'none'; aceptarBtn.removeEventListener('click', onAceptar); cancelarBtn.removeEventListener('click', onCancelar); input.removeEventListener('keydown', onKeyDown); } function onAceptar() { const v = input.value; limpiar(); resolve(v); } function onCancelar() { limpiar(); resolve(null); } function onKeyDown(e) { if (e.key === 'Enter') { e.preventDefault(); onAceptar(); } } aceptarBtn.addEventListener('click', onAceptar); cancelarBtn.addEventListener('click', onCancelar); input.addEventListener('keydown', onKeyDown); modal.style.display = 'flex'; setTimeout(() => { input.focus(); input.select(); }, 60); }); } document.getElementById('refrescarBtn').addEventListener('click', async () => { const btn = document.getElementById('refrescarBtn'); const icono = btn.querySelector('i'); btn.disabled = true; if (icono) icono.classList.add('icono-girando'); try { await sincronizacionSilenciosa(true); await loadAgotadoSet(); await loadPublicadoInfo(); if (currentItem) updatePublicadoUI(currentItem); mostrarInfoModal({ titulo: 'Sincronización', parrafos: ['Listo, se sincronizó con la nube.'], tipo: 'ok' }); } catch (e) { mostrarInfoModal({ titulo: 'Sincronización', parrafos: ['No se pudo sincronizar. Revisa tu conexión a internet e intenta de nuevo.'], tipo: 'error' }); } finally { btn.disabled = false; if (icono) icono.classList.remove('icono-girando'); } }); // Toca el indicador "equipos" de arriba para ver por qué no se está conectando a la nube. document.getElementById('appStatusArea').addEventListener('click', async () => { const areaEl = document.getElementById('appStatusArea'); const textoOriginal = document.getElementById('loadedCount').textContent; areaEl.style.opacity = '0.6'; document.getElementById('loadedCount').textContent = 'probando conexión...'; const inicio = Date.now(); try { await supaGet('vendidos'); const ms = Date.now() - inicio; mostrarInfoModal({ titulo: 'Estado de conexión', parrafos: ['Conectado correctamente (respondió en ' + ms + ' ms).', 'Los cambios se están guardando en línea.'], tipo: 'ok', }); } catch (e) { const ms = Date.now() - inicio; let explicacion; if (location.protocol === 'file:') { explicacion = 'Estás abriendo el archivo directamente desde tu computadora/celular (sin subirlo a una página web), y por eso el navegador bloquea la conexión a internet. Tienes que subir este archivo a donde tengas publicada la app (un hosting real con dirección https://) para que pueda conectarse a la nube.'; } else if (ms >= 9500) { explicacion = 'La conexión tardó demasiado y se canceló sola. Esto normalmente es señal de mala señal de datos/wifi, o de que algo en la red (antivirus, firewall, filtro de contenido) está interceptando la conexión sin responder. Prueba con otra red si puedes.'; } else { explicacion = 'Puede ser un problema de internet, un antivirus/firewall bloqueando la conexión, o que el proyecto de Supabase esté pausado o mal configurado.'; } mostrarInfoModal({ titulo: 'Estado de conexión', parrafos: ['No se pudo conectar (falló después de ' + ms + ' ms).', explicacion], detalleTecnico: 'Motivo técnico: ' + (ultimoErrorConexion || (e && e.message) || 'desconocido'), tipo: 'error', }); } finally { areaEl.style.opacity = ''; document.getElementById('loadedCount').textContent = textoOriginal; } }); document.getElementById('ocultarCanceladasToggle').addEventListener('change', () => { renderSellerStats(); }); document.getElementById('misVentasBtn').addEventListener('click', showMisVentasView); document.getElementById('agregarMercanciaBtn').addEventListener('click', agregarMercanciaFlow); document.getElementById('etiquetasBtn').addEventListener('click', generarEtiquetasFlow); cajaBtn.addEventListener('click', showCajaView); weeklyReportBtn.addEventListener('click', showReportView); backupBtn.addEventListener('click', showBackupView); cotizacionBtn.addEventListener('click', () => iniciarCotizacionFormal()); homeBtn.addEventListener('click', showScanView); listSearch.addEventListener('input', debounce(() => { updateFilterOptions(); renderList(listSearch.value); }, 200)); [filterCategoria, filterMarca, filterProc, filterRam, filterAlm, filterTouch, filterGpu].forEach(sel => { sel.addEventListener('change', () => { updateFilterOptions(); renderList(listSearch.value); }); }); filterSoloNuevos.addEventListener('change', () => { renderList(listSearch.value); }); filterSoloTouch.addEventListener('change', () => { renderList(listSearch.value); }); filterSoloGpu.addEventListener('change', () => { renderList(listSearch.value); }); filterPrecioMin.addEventListener('input', debounce(() => { renderList(listSearch.value); }, 200)); filterPrecioMax.addEventListener('input', debounce(() => { renderList(listSearch.value); }, 200)); filterAgrupar.addEventListener('change', () => { if (filterAgrupar.checked && modoSeleccion) { modoSeleccion = false; seleccionados.clear(); document.getElementById('modoSeleccionToggle').checked = false; actualizarBarraSeleccion(); } document.getElementById('modoSeleccionToggle').disabled = filterAgrupar.checked; renderList(listSearch.value); }); // --- CSV export helpers --- function csvEscape(v) { let s = (v === undefined || v === null) ? '' : String(v); // Evita inyeccion de formulas en Excel/Sheets: si el valor empieza con // = + - @ (o tab/retorno), se antepone un apostrofe para leerlo como texto. if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; return s; } function downloadCSV(filename, headers, rows) { const lines = [headers.map(csvEscape).join(',')]; rows.forEach(r => lines.push(r.map(csvEscape).join(','))); const csv = '\uFEFF' + lines.join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function escapeHtmlCell(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>'); } // Genera un archivo .xls (tabla HTML que Excel abre con formato) para poder marcar // filas completas en rojo (vendidos / eliminados) — esto no es posible en un CSV plano. function downloadStyledInventoryXls(filename, headers, rows) { let html = ''; html += ''; html += '' + headers.map(h => '').join('') + ''; rows.forEach(r => { const esRojo = r.estado === 'vendido' || r.estado === 'eliminado'; const estilo = esRojo ? ' style="background:#ffcccc;color:#b30000;font-weight:bold;"' : ''; html += '' + r.cells.map(c => '' + escapeHtmlCell(c) + '').join('') + ''; }); html += '
' + escapeHtmlCell(h) + '
'; const blob = new Blob(['\uFEFF' + html], { type: 'application/vnd.ms-excel' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } document.getElementById('downloadInvBtn').addEventListener('click', async () => { const headers = ['ID', 'Estado', 'Anaquel', 'Nivel', 'Marca', 'Modelo', 'Procesador', 'N.º de serie', 'RAM', 'Almacenamiento', 'Touch/2en1/360', 'Gráfica dedicada', 'Precio buen estado', 'Precio con detalles']; const rows = []; DATA.forEach(d => { const vendido = soldSet.has(d.id); rows.push({ estado: vendido ? 'vendido' : 'disponible', cells: [d.id, vendido ? 'VENDIDO' : 'Disponible', d.anaquel, d.nivel, d.marca, d.modelo, d.procesador, d.numeroSerie, d.ram, d.almacenamiento, d.touch, d.gpu, d.precioBueno, d.precioCosmetico], }); }); const eliminados = await getEliminados(); eliminados.forEach(d => { if (!d) return; rows.push({ estado: 'eliminado', cells: [d.id, 'ELIMINADO', d.anaquel, d.nivel, d.marca, d.modelo, d.procesador, d.numeroSerie, d.ram, d.almacenamiento, d.touch, d.gpu, d.precioBueno, d.precioCosmetico], }); }); const today = new Date().toISOString().slice(0, 10); downloadStyledInventoryXls('Inventario_' + today + '.xls', headers, rows); }); document.getElementById('downloadSalesBtn').addEventListener('click', async () => { const invSales = await getSalesWithIds('ventas_inventario'); const extSales = await getSalesWithIds('ventas_externas'); const headers = ['Tipo','Estado','ID inventario','Artículo','Precio','Método de pago','Recibido','Cambio','Comprador','Vendedor','Fecha']; const rows = []; const estadoTexto = s => (s.estado === 'cancelada' ? 'CANCELADA' : 'Completada'); invSales.forEach(s => rows.push(['Inventario', estadoTexto(s), s.id, s.marca + ' ' + s.modelo, s.precio, s.metodoPago || '', s.recibido ?? '', s.cambio ?? '', s.comprador, s.vendedor, s.fecha])); extSales.forEach(s => rows.push(['Externo', estadoTexto(s), '', s.nombre + (s.caracteristicas ? ' (' + s.caracteristicas + ')' : ''), s.precio, s.metodoPago || '', s.recibido ?? '', s.cambio ?? '', s.comprador, s.vendedor, s.fecha])); rows.sort((a, b) => new Date(a[10]) - new Date(b[10])); const today = new Date().toISOString().slice(0,10); downloadCSV('Historial_ventas_' + today + '.csv', headers, rows); }); // --- Usuarios conectados (presencia) --- let presenciaIntervalId = null; let presenciaClaveActual = null; function claveDePresencia(session) { return 'presencia:' + (session.nombre || '').toUpperCase().replace(/[^A-Z0-9]/g, '_'); } async function enviarPresencia(session, rolTexto) { try { await supaSet(claveDePresencia(session), { nombre: session.nombre, rol: rolTexto, iso: new Date().toISOString(), }); } catch (e) { /* si falla un pulso de presencia no pasa nada, se reintenta en el siguiente */ } } function iniciarPresenciaHeartbeat(session, rolTexto) { if (!hasSupabase || !session) return; detenerPresenciaHeartbeat(); presenciaClaveActual = claveDePresencia(session); enviarPresencia(session, rolTexto); presenciaIntervalId = setInterval(() => enviarPresencia(session, rolTexto), 25 * 1000); } function detenerPresenciaHeartbeat() { if (presenciaIntervalId) { clearInterval(presenciaIntervalId); presenciaIntervalId = null; } } function hace(iso) { const ms = Date.now() - new Date(iso).getTime(); const min = Math.floor(ms / 60000); if (min < 1) return 'justo ahora'; if (min === 1) return 'hace 1 minuto'; if (min < 60) return 'hace ' + min + ' minutos'; const horas = Math.floor(min / 60); if (horas === 1) return 'hace 1 hora'; if (horas < 24) return 'hace ' + horas + ' horas'; const dias = Math.floor(horas / 24); return dias === 1 ? 'hace 1 día' : 'hace ' + dias + ' días'; } async function cargarUsuariosConectados() { const cont = document.getElementById('usuariosConectadosLista'); if (!cont) return; cont.innerHTML = '

Cargando…

'; try { const filas = await supaListByPrefix('presencia:'); const usuarios = filas .map(f => f.value) .filter(v => v && v.iso) .sort((a, b) => new Date(b.iso) - new Date(a.iso)); if (!usuarios.length) { cont.innerHTML = '

No hay datos de conexión todavía.

'; return; } cont.innerHTML = ''; usuarios.forEach(u => { const enLinea = (Date.now() - new Date(u.iso).getTime()) < 90 * 1000; const fila = document.createElement('div'); fila.style.cssText = 'display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:10px;'; fila.innerHTML = '' + '' + '' + escapeHtmlCell(u.nombre) + ' · ' + escapeHtmlCell(u.rol || '') + '' + '' + '' + (enLinea ? 'en línea' : hace(u.iso)) + ''; cont.appendChild(fila); }); } catch (e) { cont.innerHTML = '

No se pudo cargar (revisa tu conexión).

'; } } const menuUsuariosConectadosBtnEl = document.getElementById('menuUsuariosConectadosBtn'); if (menuUsuariosConectadosBtnEl) { menuUsuariosConectadosBtnEl.addEventListener('click', () => { document.getElementById('menuUsuarioModal').style.display = 'none'; document.getElementById('usuariosConectadosModal').style.display = 'flex'; cargarUsuariosConectados(); }); } const usuariosConectadosCerrarBtnEl = document.getElementById('usuariosConectadosCerrarBtn'); if (usuariosConectadosCerrarBtnEl) { usuariosConectadosCerrarBtnEl.addEventListener('click', () => { document.getElementById('usuariosConectadosModal').style.display = 'none'; }); } // --- Tema claro / oscuro --- (function () { const TEMA_KEY = 'tema_preferido'; const btn = document.getElementById('themeToggleBtn'); const icon = document.getElementById('themeToggleIcon'); function aplicarTema(tema) { if (tema === 'light') { document.documentElement.setAttribute('data-theme', 'light'); if (icon) { icon.className = 'ti ti-sun'; } } else { document.documentElement.removeAttribute('data-theme'); if (icon) { icon.className = 'ti ti-moon'; } } } let guardado = null; try { guardado = localStorage.getItem(TEMA_KEY); } catch (e) {} if (guardado === 'light' || guardado === 'dark') { aplicarTema(guardado); } else { const prefiereClaro = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; aplicarTema(prefiereClaro ? 'light' : 'dark'); } if (btn) { btn.addEventListener('click', () => { const actual = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; const nuevo = actual === 'light' ? 'dark' : 'light'; aplicarTema(nuevo); try { localStorage.setItem(TEMA_KEY, nuevo); } catch (e) {} }); } })();