<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>CRM ExpoMadera — Empresas</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- SheetJS para leer/escribir Excel -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
.card:hover { transform: translateY(-4px); transition: 0.18s; }
textarea {resize: vertical;}
</style>
</head>
<body class="bg-gray-100 text-gray-800">
<div class="max-w-7xl mx-auto p-6">
<header class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<h1 class="text-2xl font-bold">📋 CRM ExpoMadera — Directorio de Empresas</h1>
<div class="flex gap-2 items-center">
<label class="bg-white px-3 py-2 rounded shadow flex items-center gap-2">
<input id="fileInput" type="file" accept=".xlsx,.xls,.csv" class="hidden" />
<button id="btnLoad" class="text-sm text-gray-700">Cargar Excel / CSV</button>
</label>
<button id="toggleView" class="bg-blue-600 text-white px-3 py-2 rounded">Cambiar a Vista Cards</button>
<button id="btnExportXLSX" class="bg-green-600 text-white px-3 py-2 rounded">Exportar Excel</button>
<button id="btnDownloadJSON" class="bg-gray-700 text-white px-3 py-2 rounded">Descargar JSON</button>
<button id="btnSendWebhook" class="bg-purple-700 text-white px-3 py-2 rounded">Enviar a Google (Webhook)</button>
</div>
</header>
<!-- Buscar / filtros -->
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<input id="searchInput" type="search" placeholder="Buscar por nombre, categoría o url..." class="px-4 py-2 rounded border" />
<select id="filterCategoria" class="px-4 py-2 rounded border">
<option value="">Filtrar por categoría (todas)</option>
</select>
<div class="flex gap-2">
<button id="btnTemplate" class="px-3 py-2 bg-yellow-500 rounded">Descargar plantilla Excel</button>
<button id="btnClearLS" class="px-3 py-2 bg-red-500 text-white rounded">Borrar datos guardados</button>
</div>
</div>
<!-- Contenedor de vistas -->
<div id="vistaCRM" class="mb-8"></div>
<div id="vistaCards" class="hidden mb-8"></div>
<footer class="text-sm text-gray-500">
Guardado local (LocalStorage). Para sincronizar con Google Sheets, crea un webhook en n8n/Make y pega la URL cuando hagas clic en "Enviar a Google".
</footer>
</div>
<script>
/* ---------------------------
UTILIDADES / Estado
--------------------------- */
const LS_KEY = "crm_expomadera_v1";
let empresas = []; // datos actuales
let vista = "CRM"; // "CRM" | "CARDS"
function cargarLocal() {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return [];
try { return JSON.parse(raw); } catch(e){ return []; }
}
function guardarLocal() {
localStorage.setItem(LS_KEY, JSON.stringify(empresas));
}
/* ---------------------------
FUNCIONES DE LECTURA (SheetJS)
- permite cargar XLSX y CSV
--------------------------- */
function readFile(file) {
const reader = new FileReader();
reader.onload = function(e) {
const data = e.target.result;
let workbook;
try {
workbook = XLSX.read(data, { type: 'binary' });
} catch(err) {
// si falla como binary, probar como arraybuffer
const arr = new Uint8Array(data);
workbook = XLSX.read(arr, { type: 'array' });
}
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const json = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
// Procesar filas y mapear campos esperados
const mapped = json.map(row => {
// normalizamos nombres de columna comunes
const lower = {};
for (const k of Object.keys(row)) lower[k.toString().toLowerCase().trim()] = row[k];
// posibles nombres de columna que buscaremos
const get = (keys) => {
for (const kk of keys) {
if (lower[kk] !== undefined) return lower[kk];
}
return "";
}
const usuario = get(["usuario @","usuario","user","handle","instagram"]) || "";
const nombre = get(["nombre","empresa","company","name"]) || "";
const bio = get(["bio","descripcion","descripcion corta","description"]) || "";
const url = get(["url instagram","instagram","instagram url","insta","url"]) || "";
const categoria = get(["categoria","sector","rubros","rubro"]) || "";
const web = get(["web","sitio web","sitio","website"]) || "";
const telefono = get(["telefono","teléfono","phone","phone number","phone_number"]) || "";
const whatsapp = get(["whatsapp","wapp","wpp"]) || "";
const email = get(["email","mail","correo"]) || "";
const observaciones = get(["observaciones","comentarios","notas"]) || "";
return {
usuario: usuario.toString(),
nombre: nombre.toString(),
bio: bio.toString(),
instagram: url.toString(),
categoria: categoria.toString(),
web: web.toString(),
telefono: telefono.toString(),
whatsapp: whatsapp.toString(),
email: email.toString(),
observaciones: observaciones.toString()
};
});
// Unir mapped con los datos actuales (append)
empresas = mergeAndNormalize(empresas.concat(mapped));
postLoad();
};
// leer como binary string (funciona con xlsx)
reader.readAsBinaryString(file);
}
function mergeAndNormalize(arr) {
// normalizamos: quitar duplicados por instagram o nombre
const seen = new Map();
for (const r of arr) {
const key = (r.instagram || "").toString().trim().toLowerCase() || (r.nombre || "").toString().trim().toLowerCase();
if (!key) continue;
if (!seen.has(key)) {
// aseguramos https en instagram url cuando es dominio
if (r.instagram && !r.instagram.startsWith("http")) {
if (r.instagram.includes("instagram.com")) r.instagram = r.instagram.startsWith("http") ? r.instagram : "https://" + r.instagram;
else r.instagram = r.instagram;
}
seen.set(key, r);
} else {
// combinar campos vacíos
const cur = seen.get(key);
for (const k of Object.keys(r)) {
if ((!cur[k] || cur[k]==="") && r[k]) cur[k] = r[k];
}
}
}
return Array.from(seen.values());
}
/* ---------------------------
RENDER CRM (tabla) y Cards
--------------------------- */
function renderCRM() {
const container = document.getElementById("vistaCRM");
if (!empresas.length) {
container.innerHTML = `<div class="bg-white p-6 rounded shadow text-gray-600">No hay datos cargados. Carga un Excel o CSV usando "Cargar Excel / CSV".</div>`;
return;
}
// tabla header
let html = `<div class="bg-white rounded shadow overflow-x-auto"><table class="min-w-full"><thead class="bg-gray-50"><tr>
<th class="p-3 text-left">Usuario @</th>
<th class="p-3 text-left">Nombre</th>
<th class="p-3 text-left">Bio</th>
<th class="p-3 text-left">Instagram</th>
<th class="p-3 text-left">Categoría</th>
<th class="p-3 text-left">Web</th>
<th class="p-3 text-left">Teléfono</th>
<th class="p-3 text-left">WhatsApp</th>
<th class="p-3 text-left">Email</th>
<th class="p-3 text-left">Observaciones</th>
<th class="p-3 text-left">Acciones</th>
</tr></thead><tbody>`;
empresas.forEach((e, i) => {
html += `<tr class="border-t">
<td class="p-2"><input value="${escapeHtml(e.usuario||'')}" onchange="updateField(${i},'usuario',this.value)" class="w-40 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.nombre||'')}" onchange="updateField(${i},'nombre',this.value)" class="w-48 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.bio||'')}" onchange="updateField(${i},'bio',this.value)" class="w-64 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.instagram||'')}" onchange="updateField(${i},'instagram',this.value)" class="w-64 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.categoria||'')}" onchange="updateField(${i},'categoria',this.value)" class="w-40 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.web||'')}" onchange="updateField(${i},'web',this.value)" class="w-48 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.telefono||'')}" onchange="updateField(${i},'telefono',this.value)" class="w-36 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.whatsapp||'')}" onchange="updateField(${i},'whatsapp',this.value)" class="w-36 border p-1 rounded"></td>
<td class="p-2"><input value="${escapeHtml(e.email||'')}" onchange="updateField(${i},'email',this.value)" class="w-44 border p-1 rounded"></td>
<td class="p-2"><textarea onchange="updateField(${i},'observaciones',this.value)" class="w-64 border p-1 rounded">${escapeHtml(e.observaciones||'')}</textarea></td>
<td class="p-2 flex gap-2">
<button onclick="openInstagram(${i})" class="px-2 py-1 bg-blue-600 text-white rounded">Instagram</button>
<button onclick="openWhatsApp(${i})" class="px-2 py-1 bg-green-600 text-white rounded">WhatsApp</button>
<button onclick="openEmail(${i})" class="px-2 py-1 bg-gray-700 text-white rounded">Email</button>
</td>
</tr>`;
});
html += `</tbody></table></div>`;
container.innerHTML = html;
}
function renderCards() {
const container = document.getElementById("vistaCards");
if (!empresas.length) {
container.innerHTML = `<div class="bg-white p-6 rounded shadow text-gray-600">No hay datos cargados.</div>`;
return;
}
let html = `<div class="grid grid-cols-1 md:grid-cols-3 gap-4">`;
empresas.forEach((e, i) => {
html += `<div class="card bg-white p-4 rounded-lg shadow">
<h3 class="font-bold text-lg">${escapeHtml(e.nombre || '—')}</h3>
<p class="text-sm text-gray-500 break-words">${escapeHtml(e.bio || '')}</p>
<p class="mt-2 text-xs text-gray-500">Instagram: <a href="${escapeAttr(e.instagram||'')}" target="_blank" class="text-blue-600 underline break-words">${escapeHtml(e.instagram||'')}</a></p>
<p class="mt-1 text-sm"><strong>Categoría:</strong> <input value="${escapeHtml(e.categoria||'')}" onchange="updateField(${i},'categoria',this.value)" class="border p-1 rounded w-full"></p>
<p class="mt-2"><strong>Tel:</strong> <input value="${escapeHtml(e.telefono||'')}" onchange="updateField(${i},'telefono',this.value)" class="border p-1 rounded w-full"></p>
<p class="mt-2"><strong>Email:</strong> <input value="${escapeHtml(e.email||'')}" onchange="updateField(${i},'email',this.value)" class="border p-1 rounded w-full"></p>
<p class="mt-2"><strong>Notas:</strong><textarea onchange="updateField(${i},'observaciones',this.value)" class="border p-1 rounded w-full">${escapeHtml(e.observaciones||'')}</textarea></p>
<div class="mt-3 flex gap-2">
<button onclick="openInstagram(${i})" class="flex-1 bg-blue-600 text-white px-3 py-2 rounded">Instagram</button>
<button onclick="openWhatsApp(${i})" class="flex-1 bg-green-600 text-white px-3 py-2 rounded">WhatsApp</button>
<button onclick="openEmail(${i})" class="flex-1 bg-gray-700 text-white px-3 py-2 rounded">Email</button>
</div>
</div>`;
});
html += `</div>`;
container.innerHTML = html;
}
/* ---------------------------
UTILIDADES UI / acciones
--------------------------- */
function escapeHtml(s){ return (s===null||s===undefined) ? '' : s.toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function escapeAttr(s){ return (s===null||s===undefined) ? '' : s.toString().replace(/"/g,'%22'); }
function updateField(i, field, value) {
empresas[i][field] = value;
guardarLocal();
updateCategoriaFilterOptions();
if (vista === "CRM") renderCRM();
else renderCards();
}
function openInstagram(i) {
const url = empresas[i].instagram || empresas[i].instagram;
if (!url) return alert("No hay URL de Instagram para esta empresa.");
window.open(url.startsWith("http") ? url : "https://"+url, "_blank");
}
function openWhatsApp(i) {
const w = empresas[i].whatsapp || empresas[i].telefono || "";
const num = w.toString().replace(/\D/g,'');
if (!num) return alert("No hay número disponible para WhatsApp.");
const text = encodeURIComponent("Hola, te contacto desde Arquitectura.News / ExpoMadera. ¿Podemos coordinar una presentación?");
window.open(`https://wa.me/${num}?text=${text}`, "_blank");
}
function openEmail(i) {
const mail = empresas[i].email || "";
if (!mail) return alert("No hay email registrado.");
window.location.href = `mailto:${mail}`;
}
/* ---------------------------
EXPORT / DESCARGA
--------------------------- */
function exportToXLSX() {
const wb = XLSX.utils.book_new();
// clonamos datos a objeto simple
const data = empresas.map(e => ({
"Usuario @": e.usuario||"",
"Nombre": e.nombre||"",
"Bio": e.bio||"",
"URL Instagram": e.instagram||"",
"Categoría": e.categoria||"",
"Web": e.web||"",
"Teléfono": e.telefono||"",
"WhatsApp": e.whatsapp||"",
"Email": e.email||"",
"Observaciones": e.observaciones||""
}));
const ws = XLSX.utils.json_to_sheet(data);
XLSX.utils.book_append_sheet(wb, ws, "Empresas");
XLSX.writeFile(wb, "CRM_ExpoMadera.xlsx");
}
function downloadJSON() {
const blob = new Blob([JSON.stringify(empresas, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "CRM_ExpoMadera.json";
a.click();
}
/* ---------------------------
WEBHOOK (Enviar a n8n/Make)
--------------------------- */
async function sendToWebhook() {
// Cambiá por la URL de tu webhook n8n/Make
const webhookUrl = prompt("Pega aquí la URL del webhook (n8n/Make):", "WEBHOOK_URL_AQUI");
if (!webhookUrl) return;
try {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(empresas)
});
if (res.ok) alert("Datos enviados correctamente al webhook.");
else alert("Error al enviar al webhook: " + res.statusText);
} catch (err) {
alert("Error al enviar: " + err.message);
}
}
/* ---------------------------
FILTROS / BUSCADOR
--------------------------- */
function applyFilters() {
const q = document.getElementById("searchInput").value.toLowerCase().trim();
const cat = document.getElementById("filterCategoria").value;
let filtered = cargarLocal();
if (q) {
filtered = filtered.filter(e => (e.nombre||"").toLowerCase().includes(q) || (e.categoria||"").toLowerCase().includes(q) || (e.instagram||"").toLowerCase().includes(q));
}
if (cat) filtered = filtered.filter(e => (e.categoria||"") === cat);
empresas = filtered;
render();
}
function updateCategoriaFilterOptions() {
const all = cargarLocal();
const cats = Array.from(new Set(all.map(e => (e.categoria||"").trim()).filter(Boolean))).sort();
const sel = document.getElementById("filterCategoria");
sel.innerHTML = `<option value="">Filtrar por categoría (todas)</option>`;
for (const c of cats) sel.innerHTML += `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`;
}
/* ---------------------------
UTIL: POST LOAD, RENDER
--------------------------- */
function postLoad() {
guardarLocal();
updateCategoriaFilterOptions();
render();
}
/* ---------------------------
Inicialización de UI / eventos
--------------------------- */
document.getElementById("btnLoad").addEventListener("click", () => document.getElementById("fileInput").click());
document.getElementById("fileInput").addEventListener("change", (ev) => {
const f = ev.target.files[0];
if (!f) return;
readFile(f);
});
document.getElementById("toggleView").addEventListener("click", () => {
vista = (vista === "CRM") ? "CARDS" : "CRM";
document.getElementById("toggleView").textContent = (vista === "CRM") ? "Cambiar a Vista Cards" : "Cambiar a Vista CRM";
render();
});
document.getElementById("searchInput").addEventListener("input", () => applyFilters());
document.getElementById("btnExportXLSX").addEventListener("click", exportToXLSX);
document.getElementById("btnDownloadJSON").addEventListener("click", downloadJSON);
document.getElementById("btnSendWebhook").addEventListener("click", sendToWebhook);
document.getElementById("btnTemplate").addEventListener("click", () => {
// descargar plantilla Excel vacía
const wb = XLSX.utils.book_new();
const sample = [{
"Usuario @":"",
"Nombre":"",
"Bio":"",
"URL Instagram":"",
"Categoría":"",
"Web":"",
"Teléfono":"",
"WhatsApp":"",
"Email":"",
"Observaciones":""
}];
const ws = XLSX.utils.json_to_sheet(sample);
XLSX.utils.book_append_sheet(wb, ws, "Plantilla");
XLSX.writeFile(wb, "plantilla_empresas.xlsx");
});
document.getElementById("btnClearLS").addEventListener("click", () => {
if (!confirm("Borrar todos los datos guardados localmente?")) return;
localStorage.removeItem(LS_KEY);
empresas = [];
render();
});
/* ---------------------------
FUNCIONES RENDER / INICIO
--------------------------- */
function render() {
if (vista === "CRM") {
document.getElementById("vistaCRM").classList.remove("hidden");
document.getElementById("vistaCards").classList.add("hidden");
renderCRM();
} else {
document.getElementById("vistaCRM").classList.add("hidden");
document.getElementById("vistaCards").classList.remove("hidden");
renderCards();
}
}
(function init() {
// cargar local al inicio
empresas = cargarLocal();
updateCategoriaFilterOptions();
render();
})();
</script>
</body>
</html>