/* ============================================= Conciliacion Colaboradores - MODIFICADO Cambios: - Soporte para archivo "Distrito" (3a hoja "HC") - Filtra TOTAL == 0 - Toma DISTRITO y ESTATUS (columna COMENTARIO) por FFM/CURP/NOMBRE - Agrega columnas DISTRITO y ESTATUS a tabla final, Excel y PDF ============================================= */ 'use strict'; /* ── Estado global ─────────────────────────── */ const State = { fileA: null, fileB: null, filesC: [], // array de archivos distrito dataA: [], dataB: [], dataC: [], // todos los registros de TODOS los archivos C indexC_ffm: new Map(), indexC_curp: new Map(), indexC_nombre: new Map(), results: [], filtered: [], currentFilter: 'all', searchTerm: '', page: 1, pageSize: 25, charts: {}, darkMode: false }; /* ── Constantes ────────────────────────────── */ const COLORS = { azulOscuro: '#12416D', azulClaro: '#2D8598', azulVerdoso: '#88C9B9', naranja: '#F8AD2A', rojo: '#DF252A', naranjaOsc: '#D4810A' }; /* ── Inicialización ────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { initDropZones(); initDarkMode(); initLogoUpload(); initFilters(); bindEvents(); }); /* ── Dark mode ─────────────────────────────── */ function initDarkMode() { const saved = localStorage.getItem('darkMode') === 'true'; if (saved) enableDark(); document.getElementById('btn-dark-mode').addEventListener('click', () => { State.darkMode ? disableDark() : enableDark(); }); } function enableDark() { State.darkMode = true; document.body.classList.add('dark-mode'); document.getElementById('btn-dark-mode').innerHTML = ''; localStorage.setItem('darkMode','true'); updateChartsTheme(); } function disableDark() { State.darkMode = false; document.body.classList.remove('dark-mode'); document.getElementById('btn-dark-mode').innerHTML = ''; localStorage.setItem('darkMode','false'); updateChartsTheme(); } function updateChartsTheme() { Object.values(State.charts).forEach(c => { if (c && c.options) { const textColor = State.darkMode ? '#A0B0C5' : '#6B7C93'; if (c.options.plugins?.legend) c.options.plugins.legend.labels.color = textColor; c.update(); } }); } /* ── Logo upload ────────────────────────────── */ function initLogoUpload() { document.querySelectorAll('.logo-placeholder').forEach(el => { el.addEventListener('click', () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const src = ev.target.result; document.querySelectorAll('.logo-placeholder').forEach(lp => { lp.innerHTML = `Logo`; }); localStorage.setItem('logoSrc', src); }; reader.readAsDataURL(file); }; inp.click(); }); }); const saved = localStorage.getItem('logoSrc'); if (saved) { document.querySelectorAll('.logo-placeholder').forEach(lp => { lp.innerHTML = `Logo`; }); } } /* ── Drag & Drop Zones ─────────────────────── */ function initDropZones() { setupDropZone('dz-a', 'input-a', 'A'); setupDropZone('dz-b', 'input-b', 'B'); // dz-c se inicializa en initDistritoZone() al mostrar resultados } function initDistritoZone() { const dz = document.getElementById('dz-c'); const input = document.getElementById('input-c'); if (!dz || !input) return; ['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); })); ['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); if (ev === 'drop') [...e.dataTransfer.files].forEach(f => addDistritoFile(f)); })); input.addEventListener('change', e => [...e.target.files].forEach(f => addDistritoFile(f))); } function addDistritoFile(file) { if (!/\.(xlsx|xls)$/i.test(file.name)) { showToast('Solo archivos .xlsx o .xls', 'error'); return; } if (State.filesC.find(f => f.name === file.name)) { showToast(`${file.name} ya está cargado`, 'error'); return; } State.filesC.push(file); readDistrito(file); renderDistritoList(); } function removeDistritoFile(fileName) { State.filesC = State.filesC.filter(f => f.name !== fileName); State.dataC = State.dataC.filter(r => r._source !== fileName); rebuildDistritoIndex(); renderDistritoList(); reapplyDistrito(); } function renderDistritoList() { const list = document.getElementById('distrito-files-list'); const badge = document.getElementById('distrito-count-badge'); if (badge) badge.textContent = State.filesC.length + ' archivo(s) cargado(s)'; if (!list) return; list.innerHTML = State.filesC.map(f => `
${f.name} cargando...
`).join(''); } function setupDropZone(dzId, inputId, label) { const dz = document.getElementById(dzId); const input = document.getElementById(inputId); ['dragenter','dragover'].forEach(ev => { dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); }); }); ['dragleave','drop'].forEach(ev => { dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); if (ev === 'drop') { const file = e.dataTransfer.files[0]; if (file) processFileSelection(file, label, dz); } }); }); input.addEventListener('change', e => { const file = e.target.files[0]; if (file) processFileSelection(file, label, dz); }); } function processFileSelection(file, label, dz) { const valid = /\.(xlsx|xls)$/i.test(file.name); if (!valid) { showToast('Solo se aceptan archivos .xlsx o .xls', 'error'); return; } if (label === 'A') State.fileA = file; else if (label === 'B') State.fileB = file; else State.fileC = file; // NUEVO const fi = dz.querySelector('.file-info'); fi.querySelector('.fi-name').textContent = file.name; fi.querySelector('.fi-date').textContent = new Date().toLocaleString('es-MX'); fi.querySelector('.fi-size').textContent = formatSize(file.size); dz.classList.add('file-loaded'); showToast(`Archivo ${label} cargado: ${file.name}`, 'success'); if (label === 'C') { readDistrito(file); // NUEVO } else { readExcel(file, label); } updateCompareBtn(); } function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB'; return (bytes/1048576).toFixed(1) + ' MB'; } /* ── Lectura de Excel con SheetJS ──────────── */ function readExcel(file, label) { const reader = new FileReader(); reader.onload = (e) => { try { const data = new Uint8Array(e.target.result); const wb = XLSX.read(data, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; let headerRow = 0; // HEAD COUNT if (file.name.toUpperCase().includes('HEAD')) { headerRow = 9; // Excel fila 10 = índice 9 } // DRAFT else { headerRow = 12; // Excel fila 13 = índice 12 } const rows = XLSX.utils.sheet_to_json(ws, { range: headerRow, defval: '' }); // Normalizar: convertir columnas a mayúsculas, limpiar strings let normalized = rows.map(row => { const out = {}; Object.keys(row).forEach(k => { out[k.trim().toUpperCase()] = String(row[k]).trim(); }); return out; }); // FILTRO HC if (file.name.toUpperCase().includes('HEAD')) { normalized = normalized.filter(row => { const seccion = normalize(row['SECCION']); const puesto = normalize(row['PUESTO']); return ( seccion === 'RESIDENCIAL' && ( puesto === 'AUXILIAR PI' || puesto === 'MOTO' || puesto === 'INSTALADOR PI' ) ); }); } // FILTRO DRAFT else { normalized = normalized.filter(row => { return normalize(row['EMPRESA EXPERTO']) === 'SOLUCIONIKA 3'; }); } // ======================================== // AUXILIARES DEL DRAFT // ======================================== if (!file.name.toUpperCase().includes('HEAD')) { const expanded = []; normalized.forEach(row => { // REGISTRO DEL TECNICO expanded.push(row); const ffmAux = normalize(row['USUARIO FFM AUXILIAR']); if ( ffmAux && ffmAux !== '#VACANTE' ) { expanded.push({ ...row, 'USUARIO FFM EXPERTO': row['USUARIO FFM AUXILIAR'], 'NOMBRE DEL EXPERTO': row['NOMBRE DEL AUXILIAR'], 'CURP EXPERTO': row['CURP AUXILIAR'] }); } }); normalized = expanded; } if (label === 'A') { State.dataA = normalized; document.querySelector('#dz-a .fi-records').textContent = normalized.length + ' registros'; } else { State.dataB = normalized; document.querySelector('#dz-b .fi-records').textContent = normalized.length + ' registros'; } } catch(err) { showToast('Error al leer el archivo Excel. Verifique el formato.', 'error'); } }; reader.readAsArrayBuffer(file); } function readDistrito(file) { const reader = new FileReader(); reader.onload = e => { try { const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); const sheetName = wb.SheetNames.find(n => n.trim().toUpperCase() === 'HC') || wb.SheetNames[0]; const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { defval: '' }); const records = rows .map(row => { const out = {}; Object.keys(row).forEach(k => { out[k.trim().toUpperCase()] = row[k]; }); return out; }) // TOTAL == 0 → sin coincidencia → son exactamente los que necesitamos .filter(row => Number(row['TOTAL']) === 0) .map(row => ({ _source: file.name, distrito: String(row['DISTRITO'] || '').trim(), estatus: String(row['ESTATUS'] || row['COMENTARIO'] || '').trim(), ffm: normalize(row['FFM']), curp: normalize(row['CURP']), nombre: normalizeName(row['NOMBRE COMPLETO'] || row['NOMBRE'] || '') })); // Agregar al pool global y reconstruir índice State.dataC.push(...records); rebuildDistritoIndex(); reapplyDistrito(); // actualizar tabla inmediatamente // Actualizar badge de registros del archivo const badgeId = 'dfi-rec-' + file.name.replace(/\W/g, '_'); const b = document.getElementById(badgeId); if (b) b.textContent = records.length + ' registros'; showToast(`${file.name}: ${records.length} registros sin coincidencia`, 'success'); } catch(err) { showToast('Error al leer archivo de distrito: ' + file.name, 'error'); } }; reader.readAsArrayBuffer(file); } function rebuildDistritoIndex() { State.indexC_ffm = new Map(); State.indexC_curp = new Map(); State.indexC_nombre = new Map(); State.dataC.forEach(rec => { const ffm = normalize(rec.ffm); // SOLO FFM válidos if ( ffm && ffm !== 'S/D' && ffm !== 'N/A' && ffm !== '#VACANTE' && ffm !== 'VACANTE' ) { if (!State.indexC_ffm.has(ffm)) { State.indexC_ffm.set(ffm, rec); } } const curp = normalize(rec.curp); if (curp) { if (!State.indexC_curp.has(curp)) { State.indexC_curp.set(curp, rec); } } const nombre = rec.nombre; if (nombre) { if (!State.indexC_nombre.has(nombre)) { State.indexC_nombre.set(nombre, rec); } } }); } // Aplica estatus a resultados ya calculados (sin re-comparar) function reapplyDistrito() { if (!State.results.length) return; State.results.forEach(r => { if (r.tipo !== 'none' && r.tipo !== 'solo-b') return; // solo sin coincidencia const dEst = lookupDistritoEstatus(r.rowA) || lookupDistritoEstatus(r.rowB); if (dEst && dEst.estatus) r.estatus = dEst.estatus; if (dEst && dEst.distrito) r.distrito = dEst.distrito; }); State.filtered = [...State.results]; applyFilters(); } /* ── NUEVO: Buscar DISTRITO/ESTATUS para un registro (rowA o rowB) ── */ function lookupDistritoEstatus(row) { if (!row) return { distrito: '', estatus: '' }; // 1) FFM // 1) FFM const ffm = normalize(getKey(row, 'FFM')); if ( ffm && ffm !== 'S/D' && ffm !== 'N/A' && ffm !== '#VACANTE' && ffm !== 'VACANTE' && State.indexC_ffm.has(ffm) ) { const rec = State.indexC_ffm.get(ffm); return { distrito: rec.distrito, estatus: rec.estatus }; } // 2) CURP (campo ID en getKey) const curp = normalize(getKey(row, 'ID')); if (curp && State.indexC_curp.has(curp)) { const rec = State.indexC_curp.get(curp); return { distrito: rec.distrito, estatus: rec.estatus }; } // 3) NOMBRE const nombre = getKey(row, 'NOMBRE'); // ya viene normalizado por normalizeName if (nombre && State.indexC_nombre.has(nombre)) { const rec = State.indexC_nombre.get(nombre); return { distrito: rec.distrito, estatus: rec.estatus }; } return { distrito: '', estatus: '' }; } /* ── Activar botón Comparar ─────────────────── */ function updateCompareBtn() { const btn = document.getElementById('btn-compare'); // El archivo C (Distrito) es opcional: no bloquea la comparación btn.disabled = !(State.fileA && State.fileB); } /* ── Motor de Comparación ───────────────────── */ function compareFiles() { if (!State.fileA || !State.fileB) return; if (State.dataA.length === 0 || State.dataB.length === 0) { showToast('Esperando lectura de archivos…', 'error'); return; } showLoader(true); setTimeout(() => runComparison(), 80); } function normalize(val) { return String(val || '').trim().toUpperCase().replace(/\s+/g, ' '); } function getKey(row, field) { // Busca el campo exacto o variaciones comunes const variations = { FFM: ['FFM','USUARIO FFM EXPERTO'], NOMBRE: ['NOMBRE DEL EXPERTO','NOMBRE COMPLETO'], ID: ['CURP','CURP EXPERTO'] }; for (const v of (variations[field] || [field])) { if (row[v] !== undefined) { if (field === 'NOMBRE') { return normalizeName(row[v]); } return normalize(row[v]); } } return ''; } function runComparison() { const dataA = State.dataA; const dataB = State.dataB; const results = []; setProgress(15, 'Indexando Archivo B…'); // Crear índices para comparación rápida const indexB_ffm = new Map(); const indexB_nombre = new Map(); const indexB_id = new Map(); const usedB = new Set(); dataB.forEach((row, i) => { const ffm = getKey(row, 'FFM'); const nombre = getKey(row, 'NOMBRE'); const id = getKey(row, 'ID'); if (ffm) { if (!indexB_ffm.has(ffm)) indexB_ffm.set(ffm, []); indexB_ffm.get(ffm).push(i); } if (nombre) { if (!indexB_nombre.has(nombre)) indexB_nombre.set(nombre, []); indexB_nombre.get(nombre).push(i); } if (id) { if (!indexB_id.has(id)) indexB_id.set(id, []); indexB_id.get(id).push(i); } }); setProgress(40, 'Comparando registros…'); // Comparar cada registro de A contra B dataA.forEach((rowA, idx) => { const ffmA = getKey(rowA, 'FFM'); const nombreA = getKey(rowA, 'NOMBRE'); const idA = getKey(rowA, 'ID'); // Candidatos de B que comparten al menos un campo const candidates = new Set(); if (ffmA) (indexB_ffm.get(ffmA) || []).forEach(i => candidates.add(i)); if (nombreA) (indexB_nombre.get(nombreA) || []).forEach(i => candidates.add(i)); if (idA) (indexB_id.get(idA) || []).forEach(i => candidates.add(i)); let bestMatch = null; let bestScore = 0; let bestRowB = null; let bestIdxB = -1; candidates.forEach(i => { const rowB = dataB[i]; const ffmB = getKey(rowB, 'FFM'); const nombreB = getKey(rowB, 'NOMBRE'); const idB = getKey(rowB, 'ID'); let matches = 0; const matchedFields = []; if (ffmA && ffmB && ffmA === ffmB) { matches++; matchedFields.push('FFM'); } if (nombreA && nombreB && nombreA === nombreB) { matches++; matchedFields.push('NOMBRE'); } if (idA && idB && idA === idB) { matches++; matchedFields.push('ID'); } if (matches > bestScore) { bestScore = matches; bestMatch = matchedFields; bestRowB = rowB; bestIdxB = i; } }); // Clasificar let tipo, porcentaje, color; if (bestScore === 3) { tipo = 'total'; porcentaje = 100; color = COLORS.azulVerdoso; } else if (bestScore === 2) { tipo = 'parcial2'; porcentaje = 66; color = COLORS.naranja; } else if (bestScore === 1) { tipo = 'parcial1'; porcentaje = 33; color = COLORS.naranjaOsc; } else { tipo = 'none'; porcentaje = 0; color = COLORS.rojo; } if (bestIdxB >= 0) usedB.add(bestIdxB); // DESPUÉS: // 1. Primero tomar DISTRITO directo del HC (rowA ya lo trae) const distritoFromHC = rowA ? normalize(rowA['DISTRITO'] || rowA['DISTRITO ORIGEN'] || '') : ''; const estatusFromDraft = rowA ? normalize(rowA['ESTATUS'] || '') : ''; let dEst = { distrito: distritoFromHC, estatus: estatusFromDraft }; // 3. Solo si siguen vacíos, buscar en archivo C if (!dEst.distrito || !dEst.estatus) { const fromC = lookupDistritoEstatus(rowA) ; if (!dEst.distrito) dEst.distrito = fromC.distrito; if (!dEst.estatus) dEst.estatus = fromC.estatus; } if (!dEst.distrito || !dEst.estatus) { const fromC2 = lookupDistritoEstatus(bestRowB); if (!dEst.distrito) dEst.distrito = fromC2.distrito; if (!dEst.estatus) dEst.estatus = fromC2.estatus; } results.push({ rowA, rowB: bestRowB, ffmA, nombreA, idA, ffmB: bestRowB ? getKey(bestRowB,'FFM') : '', nombreB: bestRowB ? getKey(bestRowB,'NOMBRE') : '', idB: bestRowB ? getKey(bestRowB,'ID') : '', matches: bestMatch || [], score: bestScore, tipo, porcentaje, color, observacion: buildObservation(tipo, bestMatch, true), source: 'both', distrito: dEst.distrito, // NUEVO estatus: dEst.estatus // NUEVO }); }); setProgress(75, 'Detectando registros exclusivos…'); // Registros solo en B dataB.forEach((rowB, i) => { if (!usedB.has(i)) { const dEst = lookupDistritoEstatus(rowB); // NUEVO results.push({ rowA: null, rowB, ffmA: '', nombreA: '', idA: '', ffmB: getKey(rowB,'FFM'), nombreB: getKey(rowB,'NOMBRE'), idB: getKey(rowB,'ID'), matches: [], score: 0, tipo: 'solo-b', porcentaje: 0, color: COLORS.azulClaro, observacion: 'No existe en Archivo A.', source: 'b', distrito: dEst.distrito, // NUEVO estatus: dEst.estatus // NUEVO }); } }); setProgress(90, 'Generando resultados…'); State.results = results; State.filtered = [...results]; State.page = 1; setTimeout(() => { setProgress(100, 'Listo'); setTimeout(() => { showLoader(false); showResultsScreen(); }, 400); }, 300); } function normalizeName(nombre) { return String(nombre || '') .toUpperCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[.,]/g, '') .trim() .replace(/\s+/g, ' ') .split(' ') .filter(x => x.length > 0) .sort() .join('|'); } function buildObservation(tipo, matches, exists) { if (!exists) return 'No existe en Archivo B.'; if (tipo === 'total') return 'Coinciden FFM, Nombre e ID.'; if (tipo === 'parcial2') return `Coinciden ${matches.join(' e ')}.`; if (tipo === 'parcial1') return `Coincide únicamente ${matches[0]}.`; return 'No existe en Archivo B.'; } /* ── Loader ─────────────────────────────────── */ function showLoader(show) { const el = document.getElementById('loader-overlay'); if (show) el.classList.add('active'); else el.classList.remove('active'); } function setProgress(pct, msg) { document.getElementById('progress-fill').style.width = pct + '%'; document.getElementById('progress-pct').textContent = pct + '%'; document.getElementById('loader-msg').textContent = msg; } /* ── Pantalla de resultados ─────────────────── */ function showResultsScreen() { document.getElementById('screen-upload').style.display = 'none'; document.getElementById('screen-results').style.display = 'block'; window.scrollTo(0, 0); renderKPIs(); renderCharts(); renderHeatmap(); renderTable(); renderReport(); initDistritoZone(); } /* ── KPIs ───────────────────────────────────── */ function renderKPIs() { const r = State.results; const total = r.length; const totalA = State.dataA.length; const totalB = State.dataB.length; const coincTotal = r.filter(x => x.tipo === 'total').length; const coincParc = r.filter(x => x.tipo === 'parcial2' || x.tipo === 'parcial1').length; const soloA = r.filter(x => x.tipo === 'none' && x.source === 'both').length + r.filter(x => x.tipo === 'solo-a').length; const soloB = r.filter(x => x.tipo === 'solo-b').length; const noneCount = r.filter(x => x.tipo === 'none' || x.tipo === 'solo-b').length; const pct = total > 0 ? Math.round((coincTotal / totalA) * 100) : 0; setKpi('kpi-a', totalA); setKpi('kpi-b', totalB); setKpi('kpi-ok', coincTotal); setKpi('kpi-parc', coincParc); setKpi('kpi-sa', soloA); setKpi('kpi-sb', soloB); setKpi('kpi-pct', pct + '%'); } function setKpi(id, val) { const el = document.getElementById(id); if (el) { el.textContent = val.toLocaleString ? val.toLocaleString() : val; } } /* ── Gráficas ───────────────────────────────── */ function renderCharts() { const r = State.results; const coincTotal = r.filter(x => x.tipo === 'total').length; const coinc2 = r.filter(x => x.tipo === 'parcial2').length; const coinc1 = r.filter(x => x.tipo === 'parcial1').length; const noneA = r.filter(x => x.tipo === 'none').length; const soloB = r.filter(x => x.tipo === 'solo-b').length; const totalA = State.dataA.length; const totalB = State.dataB.length; const textColor = State.darkMode ? '#A0B0C5' : '#6B7C93'; const globalOpts = { responsive: true, maintainAspectRatio: true, plugins: { legend: { labels: { color: textColor, font: { family:'DM Sans', size: 12 } } } } }; // Pie: distribución de coincidencias destroyChart('chart-pie'); State.charts['chart-pie'] = new Chart(document.getElementById('chart-pie'), { type: 'pie', data: { labels: ['Coincidencia Total','Parcial 2/3','Parcial 1/3','Sin Coincidencia','Solo en B'], datasets: [{ data: [coincTotal, coinc2, coinc1, noneA, soloB], backgroundColor: [COLORS.azulVerdoso, COLORS.naranja, COLORS.naranjaOsc, COLORS.rojo, COLORS.azulClaro], borderWidth: 2, borderColor: State.darkMode ? '#162333' : '#fff' }] }, options: { ...globalOpts, plugins: { ...globalOpts.plugins } } }); // Bar: Archivo A vs B (encontrados/no encontrados) const encontradosA = coincTotal + coinc2 + coinc1; const encontradosB = coincTotal + coinc2 + coinc1; destroyChart('chart-bar'); State.charts['chart-bar'] = new Chart(document.getElementById('chart-bar'), { type: 'bar', data: { labels: ['Archivo A', 'Archivo B'], datasets: [ { label: 'Encontrados', data: [encontradosA, encontradosB], backgroundColor: COLORS.azulVerdoso, borderRadius: 6 }, { label: 'No encontrados', data: [totalA - encontradosA, totalB - encontradosB], backgroundColor: COLORS.rojo, borderRadius: 6 } ] }, options: { ...globalOpts, scales: { x: { ticks: { color: textColor }, grid: { color: State.darkMode ? '#233A52':'#E8F0F8' } }, y: { ticks: { color: textColor }, grid: { color: State.darkMode ? '#233A52':'#E8F0F8' } } } } }); // Doughnut: calidad const pct = totalA > 0 ? Math.round(coincTotal/totalA*100) : 0; destroyChart('chart-doughnut'); State.charts['chart-doughnut'] = new Chart(document.getElementById('chart-doughnut'), { type: 'doughnut', data: { labels: ['Calidad de Coincidencia', 'Pendiente'], datasets: [{ data: [pct, 100-pct], backgroundColor: [COLORS.azulClaro, State.darkMode ? '#233A52' : '#E8F0F8'], borderWidth: 0 }] }, options: { ...globalOpts, cutout: '72%', plugins: { ...globalOpts.plugins, tooltip: { callbacks: { label: ctx => ctx.label + ': ' + ctx.raw + '%' } } } }, plugins: [{ id: 'center-text', afterDraw: (chart) => { const { ctx, chartArea: { width, height } } = chart; ctx.save(); ctx.font = 'bold 28px Sora, sans-serif'; ctx.fillStyle = State.darkMode ? '#E8F0FA' : COLORS.azulOscuro; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(pct + '%', width/2, (height/2) + chart.chartArea.top); ctx.restore(); } }] }); } function destroyChart(id) { if (State.charts[id]) { State.charts[id].destroy(); delete State.charts[id]; } } /* ── Heatmap por campo ──────────────────────── */ function renderHeatmap() { const r = State.results.filter(x => x.source === 'both' || x.tipo !== 'solo-b'); const total = State.dataA.length || 1; const ffm = r.filter(x => x.matches.includes('FFM')).length; const nombre = r.filter(x => x.matches.includes('NOMBRE')).length; const id = r.filter(x => x.matches.includes('ID')).length; const pctFfm = Math.round(ffm / total * 100); const pctNombre = Math.round(nombre / total * 100); const pctId = Math.round(id / total * 100); const colFfm = heatColor(pctFfm); const colNombre = heatColor(pctNombre); const colId = heatColor(pctId); document.getElementById('heatmap-wrap').innerHTML = `
FFM
${ffm.toLocaleString()}
${pctFfm}% coincidencia
Nombre
${nombre.toLocaleString()}
${pctNombre}% coincidencia
ID
${id.toLocaleString()}
${pctId}% coincidencia
`; } function heatColor(pct) { if (pct >= 80) return COLORS.azulVerdoso; if (pct >= 50) return COLORS.azulClaro; if (pct >= 25) return COLORS.naranja; return COLORS.rojo; } /* ── Tabla de resultados ─────────────────────── */ function initFilters() { document.querySelectorAll('.chip').forEach(chip => { chip.addEventListener('click', () => { document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); chip.classList.add('active'); State.currentFilter = chip.dataset.filter; State.page = 1; applyFilters(); }); }); } function applyFilters() { let data = [...State.results]; const f = State.currentFilter; const s = State.searchTerm.toUpperCase(); if (f === 'total') data = data.filter(x => x.tipo === 'total'); else if (f === 'parcial') data = data.filter(x => x.tipo === 'parcial2' || x.tipo === 'parcial1'); else if (f === 'none') data = data.filter(x => x.tipo === 'none' || x.tipo === 'solo-b'); else if (f === 'solo-a') data = data.filter(x => x.tipo === 'none'); else if (f === 'solo-b') data = data.filter(x => x.tipo === 'solo-b'); if (s) { data = data.filter(x => x.ffmA.includes(s) || x.ffmB.includes(s) || x.nombreA.includes(s) || x.nombreB.includes(s) || x.idA.includes(s) || x.idB.includes(s) || (x.distrito || '').toUpperCase().includes(s) || // NUEVO (x.estatus || '').toUpperCase().includes(s) // NUEVO ); } State.filtered = data; State.page = 1; renderTable(); } function renderTable() { const start = (State.page - 1) * State.pageSize; const end = start + State.pageSize; const pageData = State.filtered.slice(start, end); const tbody = document.getElementById('results-tbody'); if (pageData.length === 0) { // colspan ajustado: 7 columnas originales + 2 nuevas (DISTRITO, ESTATUS) = 9 tbody.innerHTML = `

No se encontraron registros con los filtros actuales.

`; } else { tbody.innerHTML = pageData.map(r => rowHtml(r)).join(''); } renderPagination(); } function badgeHtml(tipo) { const map = { 'total': ['badge-total', '✅ Coincidencia Total'], 'parcial2': ['badge-parcial2', '⚠ Parcial 2/3'], 'parcial1': ['badge-parcial1', '⚠ Parcial 1/3'], 'none': ['badge-none', '❌ Sin Coincidencia'], 'solo-b': ['badge-solo-b', '📌 Solo en B'], 'solo-a': ['badge-solo-a', '📌 Solo en A'], }; const [cls, label] = map[tipo] || ['badge-none', tipo]; return `${label}`; } function matchBarHtml(pct, tipo) { const colors = { total:COLORS.azulVerdoso, parcial2:COLORS.naranja, parcial1:COLORS.naranjaOsc, none:COLORS.rojo, 'solo-b':COLORS.azulClaro }; const color = colors[tipo] || COLORS.rojo; return `
${pct}%
`; } function fieldCell(a, b, field, matches) { const matched = matches.includes(field); const icon = matched ? '✓' : ''; const style = matched ? 'color:var(--azul-verdoso);font-weight:600' : ''; return `${a || '—'}${b || '—'}`; } function rowHtml(r) { return ` ${fieldCell(r.ffmA, r.ffmB, 'FFM', r.matches)} ${fieldCell(r.nombreA, r.nombreB, 'NOMBRE', r.matches)} ${fieldCell(r.idA, r.idB, 'ID', r.matches)} ${r.matches.length > 0 ? r.matches.join(', ') : '—'} ${matchBarHtml(r.porcentaje, r.tipo)} ${badgeHtml(r.tipo)} ${r.distrito || '—'} ${r.estatus || '—'} ${r.observacion} `; } function renderPagination() { const total = State.filtered.length; const pages = Math.max(1, Math.ceil(total / State.pageSize)); const p = State.page; const start = (p-1)*State.pageSize + 1; const end = Math.min(p*State.pageSize, total); document.getElementById('pagination-info').textContent = total === 0 ? 'Sin resultados' : `Mostrando ${start}–${end} de ${total} registros`; const wrap = document.getElementById('page-btns'); let html = ``; const maxBtn = 7; let start2 = Math.max(1, p - Math.floor(maxBtn/2)); let end2 = Math.min(pages, start2 + maxBtn - 1); if (end2 - start2 < maxBtn - 1) start2 = Math.max(1, end2 - maxBtn + 1); for (let i = start2; i <= end2; i++) { html += ``; } html += ``; wrap.innerHTML = html; } window.changePage = function(p) { State.page = p; renderTable(); document.getElementById('results-table-section').scrollIntoView({ behavior: 'smooth', block: 'start' }); }; /* ── Reporte ejecutivo ──────────────────────── */ function renderReport() { const r = State.results; const na = State.dataA.length; const nb = State.dataB.length; const coincTotal = r.filter(x => x.tipo === 'total').length; const coincParc = r.filter(x => x.tipo === 'parcial2' || x.tipo === 'parcial1').length; const sinCoinc = r.filter(x => x.tipo === 'none').length; const soloB = r.filter(x => x.tipo === 'solo-b').length; const pct = na > 0 ? Math.round(coincTotal/na*100) : 0; const fecha = new Date().toLocaleDateString('es-MX',{year:'numeric',month:'long',day:'numeric'}); document.getElementById('report-text').innerHTML = `

Se analizaron ${na.toLocaleString()} registros del Archivo A (${State.fileA.name}) y ${nb.toLocaleString()} registros del Archivo B (${State.fileB.name}) el día ${fecha}.


El motor de comparación identificó ${coincTotal.toLocaleString()} coincidencias completas (FFM + Nombre + ID), representando un ${pct}% de efectividad de conciliación. Adicionalmente, se detectaron ${coincParc.toLocaleString()} coincidencias parciales (uno o dos campos coincidentes) y ${sinCoinc.toLocaleString()} registros de Archivo A sin correspondencia en Archivo B. Por su parte, Archivo B contiene ${soloB.toLocaleString()} registros exclusivos no presentes en Archivo A.


Se recomienda revisar manualmente los registros con coincidencia parcial y los registros exclusivos de cada archivo para determinar si corresponden a errores de captura, registros duplicados o diferencias legítimas de información.

`; } /* ── Exportar Excel ─────────────────────────── */ function exportExcel() { const wb = XLSX.utils.book_new(); // Hoja de resumen const summary = [ ['RESUME'], ['Generado:', new Date().toLocaleString('es-MX')], ['Archivo A:', State.fileA.name], ['Archivo B:', State.fileB.name], ['Archivo Distrito:', State.fileC ? State.fileC.name : 'No proporcionado'], // NUEVO [], ['RESUMEN'], ['Total Archivo A', State.dataA.length], ['Total Archivo B', State.dataB.length], ['Coincidencia Total', State.results.filter(x => x.tipo==='total').length], ['Coincidencia Parcial', State.results.filter(x => x.tipo==='parcial2'||x.tipo==='parcial1').length], ['Sin Coincidencia', State.results.filter(x => x.tipo==='none').length], ['Solo en Archivo B', State.results.filter(x => x.tipo==='solo-b').length], ]; const wsSummary = XLSX.utils.aoa_to_sheet(summary); XLSX.utils.book_append_sheet(wb, wsSummary, 'Resumen'); // Hoja de detalle const headers = ['FFM Archivo A','FFM Archivo B','Nombre Archivo A','Nombre Archivo B', 'ID Archivo A','ID Archivo B','Campos Coincidentes','% Coincidencia','Estado', 'Distrito','Estatus','Observación']; // NUEVO: Distrito, Estatus const rows = [headers, ...State.results.map(r => [ r.ffmA, r.ffmB, r.nombreA, r.nombreB, r.idA, r.idB, r.matches.join(', '), r.porcentaje + '%', { total:'Coincidencia Total', parcial2:'Parcial 2/3', parcial1:'Parcial 1/3', none:'Sin Coincidencia', 'solo-b':'Solo en B' }[r.tipo] || r.tipo, r.distrito || '', // NUEVO r.estatus || '', // NUEVO r.observacion ])]; const wsDetail = XLSX.utils.aoa_to_sheet(rows); // Ancho de columnas wsDetail['!cols'] = [14,14,24,24,14,14,18,12,18,16,18,32].map(w => ({ wch: w })); // NUEVO: anchos para Distrito/Estatus XLSX.utils.book_append_sheet(wb, wsDetail, 'Detalle de Comparación'); // Hoja solo coincidencias totales const totales = State.results.filter(x => x.tipo==='total'); const wsTot = XLSX.utils.aoa_to_sheet([ headers, ...totales.map(r => [r.ffmA,r.ffmB,r.nombreA,r.nombreB,r.idA,r.idB,r.matches.join(', '),'100%','Coincidencia Total', r.distrito || '', r.estatus || '', r.observacion]) // NUEVO ]); XLSX.utils.book_append_sheet(wb, wsTot, 'Coincidencias Totales'); XLSX.writeFile(wb, `Comparacion_${Date.now()}.xlsx`); showToast('Excel exportado correctamente', 'success'); } /* ── Exportar PDF ───────────────────────────── */ async function exportPDF() { showToast('Generando PDF… un momento', 'success'); const { jsPDF } = window.jspdf; const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }); const colors = { azul: [18, 65, 109], claro: [45, 133, 152], verdoso: [136, 201, 185], naranja: [248, 173, 42], rojo: [223, 37, 42], gris: [107, 124, 147], blanco: [255, 255, 255], fondo: [244, 247, 250] }; const W = 210, M = 16; let y = 0; // ── Header ── doc.setFillColor(...colors.azul); doc.rect(0, 0, W, 32, 'F'); // Logo placeholder try { const logoSrc = localStorage.getItem('logoSrc'); if (logoSrc) doc.addImage(logoSrc, 'JPEG', M, 8, 18, 16); } catch(e) {} doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...colors.blanco); doc.text('RESUME', M + 22, 15); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(136, 201, 185); doc.text('Reporte Ejecutivo de Conciliación', M + 22, 21); doc.setTextColor(200, 220, 235); doc.text('Generado: ' + new Date().toLocaleString('es-MX'), M + 22, 27); y = 40; // ── Archivos ── doc.setFillColor(...colors.fondo); doc.rect(M, y, (W-2*M)/2 - 4, 18, 'F'); doc.rect(M + (W-2*M)/2 + 4, y, (W-2*M)/2 - 4, 18, 'F'); doc.setFont('helvetica','bold'); doc.setFontSize(8); doc.setTextColor(...colors.gris); doc.text('ARCHIVO A', M+3, y+6); doc.text('ARCHIVO B', M+(W-2*M)/2+7, y+6); doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(...colors.azul); doc.text(State.fileA.name.substring(0,35), M+3, y+13); doc.text(State.fileB.name.substring(0,35), M+(W-2*M)/2+7, y+13); y += 24; // ── KPIs ── const r = State.results; const na = State.dataA.length; const nb = State.dataB.length; const ct = r.filter(x=>x.tipo==='total').length; const cp = r.filter(x=>x.tipo==='parcial2'||x.tipo==='parcial1').length; const sa = r.filter(x=>x.tipo==='none').length; const sb = r.filter(x=>x.tipo==='solo-b').length; const pct = na>0 ? Math.round(ct/na*100) : 0; const kpis = [ { label:'Total A', val:na.toLocaleString(), color:colors.azul }, { label:'Total B', val:nb.toLocaleString(), color:colors.claro }, { label:'Coincidencias', val:ct.toLocaleString(), color:colors.verdoso }, { label:'Parciales', val:cp.toLocaleString(), color:colors.naranja }, { label:'Solo en A', val:sa.toLocaleString(), color:colors.azul }, { label:'Solo en B', val:sb.toLocaleString(), color:colors.claro }, { label:'% Efectividad', val:pct+'%', color:colors.verdoso }, ]; const kW = (W - 2*M) / 4 - 3; kpis.slice(0,4).forEach((k,i) => { const x = M + i*(kW+4); doc.setFillColor(...colors.blanco); doc.rect(x, y, kW, 22, 'F'); doc.setDrawColor(...k.color); doc.setLineWidth(0.5); doc.line(x, y, x, y+22); doc.setFont('helvetica','normal'); doc.setFontSize(7); doc.setTextColor(...colors.gris); doc.text(k.label.toUpperCase(), x+3, y+8); doc.setFont('helvetica','bold'); doc.setFontSize(14); doc.setTextColor(...k.color); doc.text(k.val, x+3, y+18); }); y += 28; kpis.slice(4).forEach((k,i) => { const kW2 = (W - 2*M) / 3 - 3; const x = M + i*(kW2+4.5); doc.setFillColor(...colors.blanco); doc.rect(x, y, kW2, 22, 'F'); doc.setDrawColor(...k.color); doc.setLineWidth(0.5); doc.line(x, y, x, y+22); doc.setFont('helvetica','normal'); doc.setFontSize(7); doc.setTextColor(...colors.gris); doc.text(k.label.toUpperCase(), x+3, y+8); doc.setFont('helvetica','bold'); doc.setFontSize(14); doc.setTextColor(...k.color); doc.text(k.val, x+3, y+18); }); y += 30; // ── Reporte ejecutivo ── doc.setFillColor(...colors.azul); doc.rect(M, y, W-2*M, 7, 'F'); doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...colors.blanco); doc.text('RESUMEN EJECUTIVO', M+3, y+5); y += 10; const fecha = new Date().toLocaleDateString('es-MX',{year:'numeric',month:'long',day:'numeric'}); const reportLines = doc.splitTextToSize( `Se analizaron ${na.toLocaleString()} registros del Archivo A y ${nb.toLocaleString()} registros del Archivo B el día ${fecha}. Se encontraron ${ct.toLocaleString()} coincidencias completas, representando un ${pct}% de efectividad. Se detectaron ${cp.toLocaleString()} coincidencias parciales y ${sa.toLocaleString()} registros sin correspondencia en Archivo B. Archivo B contiene ${sb.toLocaleString()} registros exclusivos.`, W - 2*M - 6 ); doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(...colors.azul); doc.text(reportLines, M+3, y+5); y += reportLines.length * 5 + 10; // ── Tabla de detalle (primeras 40 filas) ── doc.setFillColor(...colors.azul); doc.rect(M, y, W-2*M, 7, 'F'); doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...colors.blanco); doc.text('DETALLE DE COMPARACIÓN (primeros 40 registros)', M+3, y+5); y += 10; // NUEVO: columnas Distrito y Estatus agregadas, anchos ajustados para mantener la tabla dentro del ancho de página const cols = ['FFM-A','Nombre-A','ID-A','FFM-B','Nombre-B','ID-B','Coinciden','%','Estado','Distrito','Estatus']; const colWs = [14,22,11,14,22,11,14,8,18,16,18]; let xc = M; doc.setFillColor(232, 241, 248); doc.rect(M, y, W-2*M, 7, 'F'); doc.setFont('helvetica','bold'); doc.setFontSize(7); doc.setTextColor(...colors.azul); cols.forEach((c,i) => { doc.text(c, xc+1, y+5); xc += colWs[i]; }); y += 8; const tipoLabel = { total:'Total', parcial2:'Parcial 2/3', parcial1:'Parcial 1/3', none:'Sin Coinc.', 'solo-b':'Solo B' }; const tipoColor = { total:colors.verdoso, parcial2:colors.naranja, parcial1:colors.naranja, none:colors.rojo, 'solo-b':colors.claro }; State.results.slice(0,40).forEach((r, ri) => { if (y > 265) { doc.addPage(); y = 20; } if (ri % 2 === 0) { doc.setFillColor(...colors.fondo); doc.rect(M, y-1, W-2*M, 7, 'F'); } doc.setFont('helvetica','normal'); doc.setFontSize(6.5); doc.setTextColor(...colors.azul); const vals = [ r.ffmA.substring(0,10), r.nombreA.substring(0,14), r.idA.substring(0,8), r.ffmB.substring(0,10), r.nombreB.substring(0,14), r.idB.substring(0,8), r.matches.join('+').substring(0,12), r.porcentaje+'%', tipoLabel[r.tipo] || r.tipo, (r.distrito || '—').substring(0,14), // NUEVO (r.estatus || '—').substring(0,16) // NUEVO ]; xc = M; vals.forEach((v, i) => { if (i === 8) doc.setTextColor(...(tipoColor[r.tipo] || colors.gris)); else doc.setTextColor(...colors.azul); doc.text(v, xc+1, y+4); xc += colWs[i]; }); y += 7; }); if (State.results.length > 40) { doc.setFont('helvetica','italic'); doc.setFontSize(8); doc.setTextColor(...colors.gris); doc.text(`… y ${(State.results.length - 40).toLocaleString()} registros más. Exporte a Excel para el detalle completo.`, M, y+8); } // ── Footer ── const pageCount = doc.internal.getNumberOfPages(); for (let p = 1; p <= pageCount; p++) { doc.setPage(p); doc.setFillColor(...colors.azul); doc.rect(0, 287, W, 10, 'F'); doc.setFont('helvetica','normal'); doc.setFontSize(7); doc.setTextColor(...colors.blanco); doc.text('Conciliacion Colaboradores', M, 293); doc.text(`Página ${p} de ${pageCount}`, W - M - 20, 293); } doc.save(`Reporte_Comparacion_${Date.now()}.pdf`); showToast('PDF generado correctamente', 'success'); } /* ── Eventos generales ──────────────────────── */ function bindEvents() { document.getElementById('btn-compare').addEventListener('click', compareFiles); document.getElementById('btn-export-excel').addEventListener('click', exportExcel); document.getElementById('btn-export-pdf').addEventListener('click', exportPDF); document.getElementById('btn-reset').addEventListener('click', resetApp); document.getElementById('search-input').addEventListener('input', e => { State.searchTerm = e.target.value; applyFilters(); }); } function resetApp() { State.fileA = State.fileB = State.fileC = null; // NUEVO: incluir fileC State.dataA = State.dataB = State.dataC = State.results = State.filtered = []; State.indexC_ffm = new Map(); // NUEVO State.indexC_curp = new Map(); // NUEVO State.indexC_nombre = new Map(); // NUEVO State.page = 1; State.currentFilter = 'all'; State.searchTerm = ''; State.filesC = []; State.dataC = []; State.indexC_ffm = State.indexC_curp = State.indexC_nombre = new Map(); const list = document.getElementById('distrito-files-list'); if (list) list.innerHTML = ''; const badge = document.getElementById('distrito-count-badge'); if (badge) badge.textContent = '0 archivos cargados'; const inputC = document.getElementById('input-c'); if (inputC) inputC.value = ''; document.getElementById('screen-results').style.display = 'none'; document.getElementById('screen-upload').style.display = 'block'; ['dz-a','dz-b','dz-c'].forEach(id => { // NUEVO: incluir dz-c si existe const dz = document.getElementById(id); if (!dz) return; dz.classList.remove('file-loaded'); const input = dz.querySelector('input[type="file"]'); if (input) input.value = ''; }); document.getElementById('btn-compare').disabled = true; document.querySelectorAll('.chip').forEach(c => { c.classList.remove('active'); if (c.dataset.filter === 'all') c.classList.add('active'); }); document.getElementById('search-input').value = ''; window.scrollTo(0, 0); showToast('Aplicación reiniciada', 'success'); } /* ── Toast notifications ────────────────────── */ function showToast(msg, type = 'success') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; const icon = type === 'success' ? 'ti-circle-check' : 'ti-alert-circle'; toast.innerHTML = `${msg}`; container.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(20px)'; toast.style.transition = 'all 0.3s ease'; setTimeout(() => toast.remove(), 300); }, 3500); }