From fd0d9549cf3275eac9fd34cd90701bb46061c131 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sat, 19 Sep 2026 11:25:26 -0500 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20frontend/src/pages/Plantillas.jsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Plantillas.jsx | 633 ++++++++++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 frontend/src/pages/Plantillas.jsx diff --git a/frontend/src/pages/Plantillas.jsx b/frontend/src/pages/Plantillas.jsx new file mode 100644 index 0000000..d28a326 --- /dev/null +++ b/frontend/src/pages/Plantillas.jsx @@ -0,0 +1,633 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import { api } from "../lib/api"; + +// Lista desplegable con casillas para marcar varios. Cierra al hacer click fuera. +function MultiSelect({ label, icon, items, selected, onToggle, color = "#2563eb", bgOn = "#eff6ff", width = 240 }) { + const [abierto, setAbierto] = useState(false); + const ref = useRef(null); + useEffect(() => { + const fn = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; + document.addEventListener("mousedown", fn); + return () => document.removeEventListener("mousedown", fn); + }, []); + const n = selected.length; + const resumen = n === 0 ? label : n === 1 + ? (items.find((it) => it.v === selected[0])?.txt || `1 ${label}`) + : `${n} seleccionados`; + return ( +
+ + {abierto && ( +
+ {items.length === 0 &&
Sin opciones.
} + {items.map((it, i) => { + const on = selected.includes(it.v); + return ( + + ); + })} +
+ )} +
+ ); +} + +// Lista desplegable de plantillas (selección única con casilla). Cierra al click fuera. +function PlantillaSelect({ items, sel, onSelect, colorCat }) { + const [abierto, setAbierto] = useState(false); + const ref = useRef(null); + useEffect(() => { + const fn = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; + document.addEventListener("mousedown", fn); + return () => document.removeEventListener("mousedown", fn); + }, []); + return ( +
+ + {abierto && ( +
+ {items.length === 0 &&
Sin plantillas.
} + {items.map((p, i) => { + const on = sel && sel.nombre === p.nombre; + const c = colorCat(p.categoria); + return ( + + ); + })} +
+ )} +
+ ); +} + +export default function Plantillas({ ocultarTitulo = false }) { + const [plantillas, setPlantillas] = useState([]); + const [cargando, setCargando] = useState(true); + const [error, setError] = useState(""); + const [busca, setBusca] = useState(""); + const [sel, setSel] = useState(null); // plantilla seleccionada + + // Envío + const [telefonos, setTelefonos] = useState([]); // números leídos del Excel + const [archivo, setArchivo] = useState(""); // nombre del archivo + const [enviando, setEnviando] = useState(false); + const [resultado, setResultado] = useState(null); + + // Campañas (leyendas) para armar base + const [campanias, setCampanias] = useState([]); + const [codsSel, setCodsSel] = useState([]); // códigos marcados + const [buscandoBase, setBuscandoBase] = useState(false); + const [baseMsg, setBaseMsg] = useState(""); + // Asesores (filtro). "__VACIO__" = sin asesor. Por defecto: sin asesor marcado. + const [asesores, setAsesores] = useState([]); + const [asesSel, setAsesSel] = useState([]); // vacío = todos los asesores + const [estados, setEstados] = useState([]); // estados/objeción disponibles + const [estSel, setEstSel] = useState([]); // marcados (vacío = todos) + const [orden, setOrden] = useState("desc"); // desc = más reciente→antiguo + const [conteo, setConteo] = useState(null); // nº de teléfonos según filtros + const [contando, setContando] = useState(false); + const [limite, setLimite] = useState(""); // cuántos teléfonos exportar (vacío = todos) + const [aviso, setAviso] = useState(""); // mini pop-up de advertencia + const [enviandoBase, setEnviandoBase] = useState(false); + const [confirmEnvio, setConfirmEnvio] = useState(null); // {n} para confirmar envío + const [resEnvioBase, setResEnvioBase] = useState(null); // resultado del envío directo + + useEffect(() => { + api.plantillas() + .then((r) => { setPlantillas((r && r.plantillas) || []); setError(""); setCargando(false); }) + .catch(() => { setError("No se pudieron cargar las plantillas."); setCargando(false); }); + api.plantillasCampanias() + .then((r) => setCampanias((r && r.campanias) || [])) + .catch(() => {}); + api.plantillasAsesores() + .then((r) => setAsesores((r && r.asesores) || [])) + .catch(() => {}); + api.plantillasEstados() + .then((r) => setEstados((r && r.estados) || [])) + .catch(() => {}); + }, []); + + // Cuenta los teléfonos apenas cambia un filtro (debounce para no saturar). + useEffect(() => { + if (codsSel.length === 0) { setConteo(null); setContando(false); return; } + setContando(true); + const t = setTimeout(() => { + api.plantillasBase(codsSel, asesSel, estSel) + .then((r) => setConteo(((r && r.filas) || []).length)) + .catch(() => setConteo(null)) + .finally(() => setContando(false)); + }, 400); + return () => clearTimeout(t); + }, [codsSel, asesSel, estSel]); + + const toggleCod = (cod) => { + setCodsSel((prev) => prev.includes(cod) ? prev.filter((c) => c !== cod) : [...prev, cod]); + }; + const toggleAsesor = (a) => { + setAsesSel((prev) => prev.includes(a) ? prev.filter((x) => x !== a) : [...prev, a]); + }; + const toggleEstado = (e) => { + setEstSel((prev) => prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e]); + }; + // Borra TODOS los filtros del paso 2 (campañas, asesor, estado, orden, cantidad). + const limpiarFiltros = () => { + setCodsSel([]); setAsesSel([]); setEstSel([]); + setOrden("desc"); setLimite(""); + setBaseMsg(""); setResEnvioBase(null); setConteo(null); + }; + + // Validación en vivo del límite (solo cuando hay conteo y el campo tiene algo). + const limiteError = (() => { + if (conteo === null || limite === "") return ""; + const n = parseInt(limite, 10); + if (isNaN(n) || n < 1) return "El número debe ser 1 o mayor."; + if (n > conteo) return `El número debe estar entre 1 y ${conteo}.`; + return ""; + })(); + + // Valida filtros/límite, trae las filas, las ordena por FECHA MENSAJE y corta a N. + // Devuelve el array de filas listo, o null si hubo validación fallida (ya avisó). + async function obtenerFilasFiltradas() { + if (codsSel.length === 0) { setAviso("Debes marcar al menos una campaña."); return null; } + const lim = parseInt(limite, 10); + if (!limite || isNaN(lim)) { setAviso("Debes ingresar cuántos teléfonos."); return null; } + if (lim < 1) { setAviso("El número debe ser 1 o mayor."); return null; } + if (conteo !== null && lim > conteo) { setAviso(`El número debe estar entre 1 y ${conteo}.`); return null; } + const r = await api.plantillasBase(codsSel, asesSel, estSel); + let filas = (r && r.filas) || []; + if (filas.length === 0) { setAviso("No se encontraron números para los filtros elegidos."); return null; } + if (lim > filas.length) { setAviso(`El número debe estar entre 1 y ${filas.length}.`); return null; } + filas.sort((a, b) => { + const x = a.fecha_mensaje || "", y = b.fecha_mensaje || ""; + if (!x && !y) return 0; + if (!x) return 1; + if (!y) return -1; + return orden === "asc" ? (x < y ? -1 : x > y ? 1 : 0) : (x > y ? -1 : x < y ? 1 : 0); + }); + return filas.slice(0, lim); + } + + // Exporta la base de los códigos marcados a Excel (teléfono + 2 fechas) + async function exportarBase() { + if (buscandoBase) return; + setBuscandoBase(true); setBaseMsg(""); + try { + const filas = await obtenerFilasFiltradas(); + if (!filas) { setBuscandoBase(false); return; } + const XLSX = await import("xlsx"); + // Serial de Excel a partir de YYYY-MM-DD (fecha REAL, ordenable/filtrable). + const serialExcel = (iso) => { + if (!iso) return ""; + const [y, m, d] = iso.split("-").map(Number); + if (!y || !m || !d) return iso; + return Math.floor((Date.UTC(y, m - 1, d) - Date.UTC(1899, 11, 30)) / 86400000); + }; + // Construyo la hoja a mano para poder marcar las celdas como fecha (t:"d"/"n" con formato). + const encabezados = ["TELEFONO", "FECHA MENSAJE", "ÚLTIMO MENSAJE", "ESTADO/OBJECIÓN", "ASESOR"]; + const ws = {}; + encabezados.forEach((h, c) => { ws[XLSX.utils.encode_cell({ r: 0, c })] = { v: h, t: "s" }; }); + filas.forEach((f, ri) => { + const r = ri + 1; + ws[XLSX.utils.encode_cell({ r, c: 0 })] = { v: f.telefono, t: "s" }; + const s1 = serialExcel(f.fecha_mensaje); + const s2 = serialExcel(f.fecha_ult_mensaje); + ws[XLSX.utils.encode_cell({ r, c: 1 })] = typeof s1 === "number" ? { v: s1, t: "n", z: "dd/mm/yyyy" } : { v: s1, t: "s" }; + ws[XLSX.utils.encode_cell({ r, c: 2 })] = typeof s2 === "number" ? { v: s2, t: "n", z: "dd/mm/yyyy" } : { v: s2, t: "s" }; + ws[XLSX.utils.encode_cell({ r, c: 3 })] = { v: f.estado || "", t: "s" }; + ws[XLSX.utils.encode_cell({ r, c: 4 })] = { v: f.asesor || "", t: "s" }; + }); + ws["!ref"] = XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: filas.length, c: 4 } }); + ws["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 16 }, { wch: 22 }, { wch: 20 }]; + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Base"); + XLSX.writeFile(wb, `base_${codsSel.join("-")}.xlsx`); + setBaseMsg(`✅ ${filas.length} número(s) exportados.`); + } catch (e) { + setBaseMsg("Error: " + e.message); + } + setBuscandoBase(false); + } + + // Paso 1: valida y pide confirmación antes de enviar a la base filtrada. + async function pedirEnvioBase() { + if (buscandoBase || enviandoBase) return; + if (!sel) { setAviso("Elige una plantilla de la lista de arriba."); return; } + setBuscandoBase(true); setBaseMsg(""); setResEnvioBase(null); + try { + const filas = await obtenerFilasFiltradas(); + if (!filas) { setBuscandoBase(false); return; } + setConfirmEnvio({ n: filas.length, filas }); + } catch (e) { + setBaseMsg("Error: " + e.message); + } + setBuscandoBase(false); + } + + // Paso 2: envía la plantilla a los teléfonos ya filtrados (tras confirmar). + async function enviarBaseConfirmado() { + const filas = confirmEnvio && confirmEnvio.filas; + setConfirmEnvio(null); + if (!filas || !sel) return; + setEnviandoBase(true); setResEnvioBase(null); setBaseMsg(""); + try { + const nums = filas.map((f) => f.telefono).filter(Boolean); + const r = await api.plantillaEnviar({ + nombre: sel.nombre, categoria: sel.categoria || "MARKETING", + idioma: "es_PE", texto: sel.texto || "", telefonos: nums, + }); + setResEnvioBase(r); + } catch (e) { + setResEnvioBase({ error: "Error al enviar: " + e.message }); + } + setEnviandoBase(false); + } + + const filtradas = useMemo(() => { + const q = busca.trim().toLowerCase(); + if (!q) return plantillas; + return plantillas.filter((p) => + (p.nombre || "").toLowerCase().includes(q) || + (p.categoria || "").toLowerCase().includes(q)); + }, [plantillas, busca]); + + const colorCat = (c) => { + const u = String(c || "").toUpperCase(); + if (u === "MARKETING") return { bg: "#eff6ff", fg: "#1e40af", bd: "#bfdbfe" }; + if (u === "UTILITY") return { bg: "#ecfdf5", fg: "#047857", bd: "#a7f3d0" }; + if (u === "AUTHENTICATION") return { bg: "#fffbeb", fg: "#b45309", bd: "#fde68a" }; + return { bg: "#f1f5f9", fg: "#475569", bd: "#e2e8f0" }; + }; + + // Leer el Excel y extraer la columna TELEFONO + async function importarExcel(e) { + const file = e.target.files?.[0]; + if (!file) return; + setResultado(null); + const XLSX = await import("xlsx"); + const buf = await file.arrayBuffer(); + const wb = XLSX.read(buf, { type: "array" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const filas = XLSX.utils.sheet_to_json(ws, { defval: "" }); + // buscar la columna "telefono" (sin importar mayúsculas/espacios) + const nums = []; + for (const fila of filas) { + let val = ""; + for (const k of Object.keys(fila)) { + if (k.trim().toLowerCase() === "telefono") { val = fila[k]; break; } + } + const s = String(val).trim(); + if (s) nums.push(s); + } + setTelefonos(nums); + setArchivo(file.name); + } + + async function enviar() { + if (!sel || telefonos.length === 0 || enviando) return; + setEnviando(true); setResultado(null); + try { + const r = await api.plantillaEnviar({ + nombre: sel.nombre, categoria: sel.categoria || "MARKETING", + idioma: "es_PE", texto: sel.texto || "", telefonos, + }); + setResultado(r); + } catch (err) { + setResultado({ error: "Error al enviar: " + err.message }); + } + setEnviando(false); + } + + // Estilos reutilizables + const card = { background: "#fff", border: "1px solid #e6eaf0", borderRadius: 14, + padding: 20, boxShadow: "0 1px 3px rgba(15,23,42,.04)" }; + const cardHead = { display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }; + const step = { width: 26, height: 26, borderRadius: 8, background: "#1e3a5f", color: "#fff", + display: "flex", alignItems: "center", justifyContent: "center", + fontSize: 13, fontWeight: 800, flexShrink: 0 }; + const cardTitle = { fontSize: 15, fontWeight: 700, color: "#0f172a" }; + const cardSub = { fontSize: 12.5, color: "#94a3b8", margin: "2px 0 0 36px" }; + const fieldLabel = { fontSize: 12, fontWeight: 700, color: "#64748b", marginBottom: 7, + textTransform: "uppercase", letterSpacing: ".02em" }; + + return ( +
+ {!ocultarTitulo &&

📄 Plantillas

} + + {/* PASO 1 — Elegir plantilla */} +
+
+ 1 + Elige la plantilla + + {filtradas.length} disponibles + +
+
La plantilla que se enviará a los teléfonos.
+
+ setBusca(e.target.value)} + placeholder="🔍 Buscar por nombre o categoría..." + style={{ padding: "9px 14px", border: "1px solid #cbd5e1", borderRadius: 10, + fontSize: 13, width: 280 }} /> +
+ {cargando ? ( +
Cargando plantillas...
+ ) : error ? ( +
{error}
+ ) : ( + { setSel(p); setResultado(null); }} colorCat={colorCat} /> + )} +
+
+ {sel && ( +
+ ✓ Plantilla: + {sel.nombre} +
+ )} +
+ + {/* PASO 2 — Armar base y enviar plantilla */} +
+
+ 2 + Armar base y enviar plantilla +
+
+
Marca campañas, aplica filtros y exporta o envía a esa base.
+ +
+ + {/* Fila 1: campaña + asesor */} +
+
+
🏷️ Campaña(s)
+ ({ v: c.codigo, txt: c.codigo, title: (c.frases || []).join(" | ") }))} + selected={codsSel} onToggle={toggleCod} + color="#2563eb" bgOn="#eff6ff" width="100%" /> +
+
+
👤 Asesor
+ ({ v: a, txt: a }))]} + selected={asesSel} onToggle={toggleAsesor} + color="#10b981" bgOn="#ecfdf5" width="100%" /> +
+
+ + {/* Fila 2: estado + orden */} +
+
+
🏷️ Estado / Objeción
+ ({ v: e, txt: e }))} + selected={estSel} onToggle={toggleEstado} + color="#7c3aed" bgOn="#f5f3ff" width="100%" /> +
+
+
📅 Ordenar por fecha
+ +
+
+ + {/* Fila 3: cantidad a enviar/exportar */} +
+
+ 🔢 Cantidad a enviar / exportar {conteo !== null && (1–{conteo})} +
+
+ setLimite(e.target.value)} + placeholder={conteo !== null ? `1 – ${conteo}` : "cantidad"} + style={{ padding: "9px 14px", borderRadius: 10, fontSize: 13, fontWeight: 600, flex: 1, minWidth: 0, + background: "#fff", color: "#0f172a", + border: `1px solid ${limiteError ? "#ef4444" : "#cbd5e1"}` }} /> + +
+ {limiteError && ( +
⚠️ {limiteError}
+ )} +
+ + {/* Barra de resumen + acciones */} +
+ + {codsSel.length === 0 ? "📱 marca una campaña" + : contando ? "📱 contando…" + : conteo === null ? "📱 —" + : `📱 ${conteo} teléfono(s)`} + +
+ + +
+
+ Se enviará: + {sel ? {sel.nombre} + : elige la plantilla en el paso 1 ↑} +
+ {baseMsg && ( +
{baseMsg}
+ )} + {resEnvioBase && ( +
+ {resEnvioBase.error ? resEnvioBase.error : ( + <>✅ Enviados: {resEnvioBase.enviados} · + ❌ Fallidos: {(resEnvioBase.fallidos || []).length} · + ⚠️ Sin código 51: {(resEnvioBase.invalidos || []).length} + )} +
+ )} +
+ + {/* PASO 3 — Importar base y enviar plantilla */} +
+
+ 3 + Importar base y enviar plantilla + opcional +
+
Si ya tienes una lista de teléfonos en Excel, impórtala y envía la plantilla.
+ +
+ + {archivo && ( + + {archivo} · {telefonos.length} número(s) + + )} + +
+ + {resultado && ( +
+ {resultado.error ? resultado.error : ( + <> + ✅ Enviados: {resultado.enviados} · + ❌ Fallidos: {(resultado.fallidos || []).length} · + ⚠️ Sin código 51 (no enviados): {(resultado.invalidos || []).length} + {(resultado.invalidos || []).length > 0 && ( +
+ Rechazados por no tener código 51: {resultado.invalidos.join(", ")} +
+ )} + + )} +
+ )} +
+ El Excel debe tener una columna TELEFONO. Solo se envía a números peruanos con código 51 (ej. 51966319975). +
+
+ + {/* Pop-up de confirmación de envío directo a la base */} + {confirmEnvio && ( +
setConfirmEnvio(null)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,.35)", zIndex: 100, + display: "flex", alignItems: "center", justifyContent: "center" }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, padding: "24px 28px", maxWidth: 380, + boxShadow: "0 20px 50px rgba(15,23,42,.25)", textAlign: "center" }}> +
🚀
+
+ Enviar la plantilla {sel && sel.nombre} a
{confirmEnvio.n} teléfono(s)? +
+
Esta acción no se puede deshacer.
+
+ + +
+
+
+ )} + + {/* Mini pop-up de advertencia */} + {aviso && ( +
setAviso("")} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,.35)", zIndex: 100, + display: "flex", alignItems: "center", justifyContent: "center" }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, padding: "22px 26px", maxWidth: 340, + boxShadow: "0 20px 50px rgba(15,23,42,.25)", textAlign: "center" }}> +
⚠️
+
{aviso}
+ +
+
+ )} +
+ ); +}