import { useState, useEffect, useRef } from "react"; import { api } from "../lib/api"; import Plantillas from "../pages/Plantillas"; // Desplegable con búsqueda (estilo Claude). function ProgramaSelect({ valor, opciones = [], onSelect }) { const [abierto, setAbierto] = useState(false); const [q, setQ] = useState(""); const ref = useRef(null); useEffect(() => { function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); } document.addEventListener("mousedown", fuera); return () => document.removeEventListener("mousedown", fuera); }, []); const filtradas = opciones.filter((o) => o.label.toLowerCase().includes(q.toLowerCase())); return (
{abierto && (
setQ(e.target.value)} placeholder="Buscar..." style={{ width: "100%", padding: "8px 11px", border: "1px solid #cbd5e1", borderRadius: 8, fontSize: 13, fontFamily: "inherit", outline: "none" }} />
{filtradas.length === 0 ? (
Sin opciones
) : filtradas.map((o) => (
e.currentTarget.style.background = "#f1f5f9"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"} onMouseDown={(e) => e.preventDefault()} onClick={() => { onSelect && onSelect(o); setAbierto(false); setQ(""); }}> {o.label}
))}
)}
); } // Apartado "Sedes de Anuncios": conjuntos del CSV sin sede -> asignar sede manual. // FASE 1: solo vista (dropdown de sede por fila). El guardar se agrega despues. function SedesAnuncios() { const [filas, setFilas] = useState([]); const [cargando, setCargando] = useState(true); const [sel, setSel] = useState({}); // conjunto -> sede elegida (decorativo por ahora) const [selProg, setSelProg] = useState({}); // conjunto -> programa elegido (decorativo) const [busca, setBusca] = useState(""); // Opciones fijas (no se pueden borrar) y opciones ampliables con "Agregar". const SEDES_FIJAS = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO"]; const PROGS_FIJOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]; const [sedes, setSedes] = useState(SEDES_FIJAS); const [programas, setProgramas] = useState(PROGS_FIJOS); const SEDES = sedes; const PROGRAMAS = programas; // Quita una opción creada (no fija): la saca del dropdown y de cualquier // conjunto que la tenga asignada (se aplica al Guardar). function quitarOpcion(tipo, val) { if (tipo === "TIPO") { if (SEDES_FIJAS.includes(val)) return; setSedes((s) => s.filter((x) => x !== val)); setSel((cur) => { const n = { ...cur }; Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; }); return n; }); } else { if (PROGS_FIJOS.includes(val)) return; setProgramas((p) => p.filter((x) => x !== val)); setSelProg((cur) => { const n = { ...cur }; Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; }); return n; }); } } // Pop-up "Agregar" const [popup, setPopup] = useState(false); const [nvTipo, setNvTipo] = useState("TIPO"); // "TIPO" o "PROGRAMA" const [nvValor, setNvValor] = useState(""); function agregar() { const val = nvValor.trim().toUpperCase(); if (!val) return; if (nvTipo === "TIPO") { if (!sedes.includes(val)) setSedes((s) => [...s, val]); } else { if (!programas.includes(val)) setProgramas((p) => [...p, val]); } setNvValor(""); setPopup(false); } const [guardando, setGuardando] = useState(false); const [msg, setMsg] = useState(""); useEffect(() => { api.conjuntosSinSede() .then((r) => { const rows = r || []; setFilas(rows); // precargar sede/programa ya asignados + ampliar opciones si hay valores nuevos const s0 = {}, p0 = {}; const sExtra = new Set(), pExtra = new Set(); rows.forEach((f) => { if (f.sede) { s0[f.conjunto] = f.sede; sExtra.add(f.sede); } if (f.programa) { p0[f.conjunto] = f.programa; pExtra.add(f.programa); } }); setSel(s0); setSelProg(p0); setSedes((cur) => [...cur, ...[...sExtra].filter((x) => !cur.includes(x))]); // Carga los programas ya asignados (incluye los nuevos que crees, ej. // BRANDING). El dashboard ahora SÍ reconoce cualquier programa asignado. setProgramas((cur) => [...cur, ...[...pExtra].filter((x) => !cur.includes(x))]); setCargando(false); }) .catch(() => setCargando(false)); }, []); async function guardar() { setGuardando(true); setMsg(""); try { const cambios = filas .filter((f) => sel[f.conjunto] || selProg[f.conjunto]) .map((f) => ({ conjunto: f.conjunto, sede: sel[f.conjunto] || "", programa: selProg[f.conjunto] || "" })); await api.guardarConjuntoSede(cambios); setMsg("✓ Guardado. Se reflejará en ROAS."); window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados } catch (e) { setMsg("Error al guardar"); } setGuardando(false); } async function borrar(conjunto) { // limpia la seleccion en pantalla y borra la asignacion en Supabase setSel((s) => { const n = { ...s }; delete n[conjunto]; return n; }); setSelProg((s) => { const n = { ...s }; delete n[conjunto]; return n; }); try { await api.borrarConjuntoSede(conjunto); setMsg("✓ Asignación borrada."); window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados } catch (e) { setMsg("Error al borrar"); } } const vis = filas.filter((f) => f.conjunto.toLowerCase().includes(busca.toLowerCase())); return ( <>
setBusca(e.target.value)} placeholder="Buscar conjunto de anuncio..." style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} />
Conjuntos de anuncios sin sede asignada. Elige la sede y el programa de cada uno.
{/* Pop-up Agregar nueva opción (Tipo o Programa) */} {popup && (
setPopup(false)} style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", zIndex: 60, display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ background: "#fff", borderRadius: 14, width: 360, padding: 22, boxShadow: "0 20px 50px rgba(0,0,0,0.3)" }}>
Agregar nueva opción
setNvValor(e.target.value)} onKeyDown={(e) => e.key === "Enter" && agregar()} placeholder="Ej: CHICLAYO" style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1", borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }} /> {/* Lista de opciones CREADAS (no fijas) para poder borrarlas */} {(() => { const esSede = nvTipo === "TIPO"; const lista = (esSede ? sedes : programas) .filter((x) => !(esSede ? SEDES_FIJAS : PROGS_FIJOS).includes(x)); if (lista.length === 0) return null; return (
{esSede ? "Sedes creadas" : "Programas creados"} (clic en × para borrar)
{lista.map((x) => ( {x} ))}
); })()}
)}
{cargando ? (
Cargando...
) : (
{vis.map((f, i) => ( ))} {vis.length === 0 && ( )}
Conjunto de Anuncio Tipo Programa
{f.conjunto} {(sel[f.conjunto] || selProg[f.conjunto]) && ( )}
No hay conjuntos sin sede.
)}
{msg || `${vis.length} conjuntos sin sede`}
); } // Apartado "Programas": lista de programas con contar=NO (ocultos), con switch para reactivar. function ProgramasOcultos() { const [filas, setFilas] = useState([]); const [cargando, setCargando] = useState(true); const [enc, setEnc] = useState({}); // num_indice -> true si se va a encender const [guardando, setGuardando] = useState(false); const [msg, setMsg] = useState(""); useEffect(() => { api.programasOcultos() .then((r) => { setFilas(r.filas || []); setCargando(false); }) .catch(() => setCargando(false)); }, []); async function guardar() { const nis = Object.entries(enc).filter(([, v]) => v).map(([ni]) => ni); if (nis.length === 0) { setMsg("No hay programas para activar."); return; } setGuardando(true); setMsg(""); try { await api.encenderProgramas(nis); setMsg("✓ Activados. Ya aparecen en la matriz."); const r = await api.programasOcultos(); setFilas(r.filas || []); setEnc({}); window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados } catch (e) { setMsg("Error: " + e.message); } finally { setGuardando(false); } } return ( <>
Aquí están los programas ocultos del Detalle por Curso. Enciende el interruptor y guarda para que vuelvan a aparecer en la tabla del dashboard.
{cargando ? (
Cargando...
) : filas.length === 0 ? (
No hay programas ocultos.
) : (
{filas.map((f) => { const on = !!enc[f.num_indice]; return (
{f.dsc_det_programa}
Índice {f.num_indice}{f.fch_inicio ? ` · ${f.fch_inicio}` : ""}{f.pauta ? ` · Pauta ${f.pauta}` : ""}
setEnc((p) => ({ ...p, [f.num_indice]: !p[f.num_indice] }))} style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer", flexShrink: 0, background: on ? "#2563eb" : "#cbd5e1", position: "relative", transition: "background .15s" }}>
); })}
)}
{msg}
); } // ── Mini-apartado LEYENDA: tabla de campanias + Agregar (popup) + eliminar (visual) ── function LeyendaCampanias() { const CARGOS = ["TEAC", "TERC", "VRF", "CO2", "Seminarios", "Diseño de Chillers", "Diplomado REF", "CAD", "Amoniaco", "Cámaras", "Diseño de Sistemas", "Metrado y Costeo"]; const SEDES = ["Lima", "Piura", "Arequipa", "Trujillo"]; const ORIGENES = ["Pauta_wsp", "Web_Whatsapp"]; const DIAS = ["Domingo", "Sábado", "Semipresencial", "-"]; const [filas, setFilas] = useState([]); const [cargando, setCargando] = useState(true); const [busca, setBusca] = useState(""); const [popup, setPopup] = useState(false); const [editandoId, setEditandoId] = useState(null); // id de la fila en edicion, o null = agregar const [confirmar, setConfirmar] = useState(null); // fila a eliminar, o null const [choques, setChoques] = useState(null); // [filas que chocan] o null (popup de aviso) const [guardando, setGuardando] = useState(false); const [msg, setMsg] = useState(""); const nuevaVacia = { frase_busqueda: "", cargo: "TEAC", codigo: "", sede: "Lima", dia: "-", origen: "Pauta_wsp", fecha_inicio: "", fecha_fin: "" }; const [nueva, setNueva] = useState(nuevaVacia); function recargar() { setCargando(true); api.campanias().then((r) => { setFilas(r.filas || []); setCargando(false); }) .catch(() => setCargando(false)); } useEffect(() => { recargar(); }, []); const vis = filas.filter((f) => !busca || ((f.frase_busqueda || "") + (f.cargo || "") + (f.codigo || "") + (f.sede || "")).toLowerCase().includes(busca.toLowerCase())); // Extrae los emojis de un texto (pictográficos). Devuelve un Set de emojis. function emojisDe(txt) { const s = new Set(); for (const ch of String(txt || "")) { const cp = ch.codePointAt(0); // rangos de emojis/pictogramas comunes if ((cp >= 0x1F000 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0x2B00 && cp <= 0x2BFF) || cp === 0x2764 || (cp >= 0x2190 && cp <= 0x21FF)) { s.add(ch); } } return s; } // ¿los rangos [i1,f1] y [i2,f2] se solapan? (fechas en texto YYYY-MM-DD) function fechasSolapan(i1, f1, i2, f2) { if (!i1 || !f1 || !i2 || !f2) return false; return i1 <= f2 && i2 <= f1; } // Devuelve las campañas existentes que chocan con "nueva": // mismo emoji compartido Y fechas solapadas. function detectarChoques() { const emN = emojisDe(nueva.frase_busqueda); if (emN.size === 0) return []; const iN = (nueva.fecha_inicio || "").slice(0, 10); const fN = (nueva.fecha_fin || "").slice(0, 10); return filas.filter((f) => { if (editandoId != null && f.id === editandoId) return false; // no chocar contra si misma const emE = emojisDe(f.frase_busqueda); const comparte = [...emN].some((e) => emE.has(e)); // ¿comparten algún emoji? if (!comparte) return false; return fechasSolapan(iN, fN, (f.fecha_inicio || "").slice(0, 10), (f.fecha_fin || "").slice(0, 10)); }); } function abrirAgregar() { setEditandoId(null); setNueva(nuevaVacia); setMsg(""); setPopup(true); } function abrirEditar(f) { setEditandoId(f.id); setNueva({ frase_busqueda: f.frase_busqueda || "", cargo: f.cargo || "TEAC", codigo: f.codigo || "", sede: f.sede || "Lima", dia: f.dia || "-", origen: f.origen || "Pauta_wsp", fecha_inicio: (f.fecha_inicio || "").slice(0, 10), fecha_fin: (f.fecha_fin || "").slice(0, 10), }); setMsg(""); setPopup(true); } async function agregar() { if (!nueva.frase_busqueda.trim()) { setMsg("Escribe la frase de búsqueda."); return; } // Aviso de choque: mismo emoji + fechas solapadas. Si las fechas NO chocan, no avisa. const ch = detectarChoques(); if (ch.length > 0) { setChoques(ch); return; } await guardarCampania(); } async function guardarCampania() { setChoques(null); setGuardando(true); setMsg(""); try { if (editandoId != null) await api.editarCampania(editandoId, nueva); else await api.agregarCampania(nueva); setPopup(false); setNueva(nuevaVacia); setEditandoId(null); recargar(); window.dispatchEvent(new Event("datos-actualizar")); } catch (e) { setMsg("Error al guardar: " + e.message); } finally { setGuardando(false); } } async function borrarConfirmado() { const f = confirmar; setConfirmar(null); try { await api.borrarCampania(f.id); recargar(); window.dispatchEvent(new Event("datos-actualizar")); } catch (e) { setMsg("Error al borrar: " + e.message); } } const inp = { width: "100%", height: 38, border: "1px solid #e2e8f0", borderRadius: 8, padding: "0 11px", fontSize: 13, boxSizing: "border-box" }; const lab = { fontSize: 12.5, fontWeight: 600, color: "#475569", display: "block", marginBottom: 5 }; return ( <>
setBusca(e.target.value)} placeholder="Buscar frase, cargo, código o sede..." style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} />
{["Frase de búsqueda", "Cargo", "Código", "Sede", "Día", "Origen", "Inicio", "Fin", ""].map((h, i, arr) => ( ))} {cargando && ( )} {!cargando && vis.length === 0 && ( )} {!cargando && vis.map((f) => ( ))}
{h}
Cargando...
Sin leyendas.
{f.frase_busqueda} {f.cargo} {f.codigo} {f.sede} {f.dia} {f.origen} {f.fecha_inicio} {f.fecha_fin}
{/* Popup Agregar leyenda */} {popup && (
setPopup(false)} style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1300 }}>
e.stopPropagation()} style={{ background: "#fff", borderRadius: 14, width: 520, maxWidth: "94vw", boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}>
{editandoId != null ? "Editar leyenda" : "Agregar leyenda"}
setNueva({ ...nueva, frase_busqueda: e.target.value })} />
setNueva({ ...nueva, codigo: e.target.value })} />
setNueva({ ...nueva, fecha_inicio: e.target.value })} />
setNueva({ ...nueva, fecha_fin: e.target.value })} />
{msg &&
{msg}
}
)} {/* Popup de confirmación de eliminación */} {confirmar !== null && (
setConfirmar(null)} style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1400 }}>
e.stopPropagation()} style={{ background: "#fff", borderRadius: 14, width: 400, maxWidth: "92vw", boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden", textAlign: "center" }}>
🗑️
¿Eliminar esta leyenda?
{confirmar && ( <>Se eliminará la pauta {confirmar.codigo} {" "}({confirmar.cargo} — {confirmar.sede}). Esta acción no se puede deshacer. )}
)} {/* Popup de ADVERTENCIA de choque: mismo emoji + fechas solapadas */} {choques !== null && (
setChoques(null)} style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1500 }}>
e.stopPropagation()} style={{ background: "#fff", borderRadius: 14, width: 460, maxWidth: "94vw", boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}>
⚠️
Esta leyenda podría duplicar leads
La frase que vas a crear comparte emoji y fechas con {choques.length === 1 ? "esta pauta existente" : `estas ${choques.length} pautas existentes`}. Un mismo mensaje podría contarse dos veces.
{choques.map((c) => (
Pauta {c.codigo} ({c.cargo} — {c.sede})
“{c.frase_busqueda}”
Vigente: {c.fecha_inicio} → {c.fecha_fin}
))}
)} ); } export default function ConfigModal({ onClose }) { const [seccion, setSeccion] = useState("leyenda"); const [filas, setFilas] = useState([]); const [cargando, setCargando] = useState(true); const [guardando, setGuardando] = useState(false); const [msg, setMsg] = useState(""); const [busca, setBusca] = useState(""); const [edit, setEdit] = useState({}); // conjunto -> pauta editada const [editProg, setEditProg] = useState({}); // conjunto -> {num_indice, label} programa elegido const [progOpc, setProgOpc] = useState([]); // opciones del desplegable de Programa useEffect(() => { api.leyendaAnuncios() .then((r) => { setFilas(r.filas || []); setCargando(false); }) .catch(() => setCargando(false)); api.programasDisponibles() .then((r) => setProgOpc(r.programas || [])) .catch(() => setProgOpc([])); }, []); const setPauta = (conjunto, val) => setEdit((p) => ({ ...p, [conjunto]: val })); const valorPauta = (f) => (edit[f.conjunto] !== undefined ? edit[f.conjunto] : (f.pauta || "")); // Programa mostrado: el elegido si hay, si no el que ya trae la fila const valorPrograma = (f) => (editProg[f.conjunto] !== undefined ? editProg[f.conjunto].label : (f.programa || "")); async function guardar() { // Un cambio por conjunto que tenga pauta editada o programa elegido const conjuntos = new Set([...Object.keys(edit), ...Object.keys(editProg)]); const cambios = []; for (const conjunto of conjuntos) { const f = filas.find((x) => x.conjunto === conjunto) || {}; const pauta = (edit[conjunto] !== undefined ? edit[conjunto] : (f.pauta || "")).trim(); const prog = editProg[conjunto]; // {num_indice, label} o undefined cambios.push({ conjunto, pauta, num_indice: prog ? prog.num_indice : null }); } if (cambios.length === 0) { setMsg("No hay cambios."); return; } setGuardando(true); setMsg(""); try { await api.guardarLeyenda(cambios); setMsg("✓ Guardado correctamente."); const r = await api.leyendaAnuncios(); setFilas(r.filas || []); setEdit({}); setEditProg({}); window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados } catch (e) { setMsg("Error: " + e.message); } finally { setGuardando(false); } } // Orden: 1) pauta Y programa, 2) pauta sin programa, 3) programa sin pauta, 4) nada const _rango = (f) => { const p = valorPauta(f).trim() !== ""; const g = (f.programa || "").trim() !== ""; if (p && g) return 0; if (p && !g) return 1; if (!p && g) return 2; return 3; }; const filtradas = filas .filter((f) => !busca || f.conjunto.toLowerCase().includes(busca.toLowerCase()) || String(f.pauta).includes(busca) || (f.programa || "").toLowerCase().includes(busca.toLowerCase())) .sort((a, b) => (_rango(a) - _rango(b)) || a.conjunto.localeCompare(b.conjunto)); return (
e.stopPropagation()} style={{ background: "#fff", borderRadius: 16, width: 1320, maxWidth: "96vw", height: 680, maxHeight: "92vh", display: "flex", overflow: "hidden", boxShadow: "0 24px 70px rgba(0,0,0,0.4)", fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}> {/* Sidebar del modal */}
Configuración
{/* Contenido */}
{seccion === "leyenda" ? "Leyenda de Anuncios" : seccion === "programas" ? "Programas" : seccion === "leyendas" ? "Leyenda" : seccion === "plantillas" ? "Plantillas" : "Sedes de Anuncios"}
{seccion === "plantillas" ? (
) : seccion === "leyendas" ? ( ) : seccion === "sedes" ? ( ) : seccion === "programas" ? ( ) : ( <>
setBusca(e.target.value)} placeholder="Buscar conjunto, pauta o programa..." style={{ width: "100%", padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} />
{cargando ? (
Cargando...
) : (
{filtradas.map((f) => ( ))}
Conjunto de Anuncios Pauta Programa
{f.conjunto} setPauta(f.conjunto, e.target.value)} style={{ width: 90, padding: "6px 8px", border: "1px solid #cbd5e1", borderRadius: 6, fontSize: 13, textAlign: "left", fontFamily: "inherit", background: edit[f.conjunto] !== undefined ? "#fffbeb" : "#fff" }} /> setEditProg((p) => ({ ...p, [f.conjunto]: o }))} />
)}
{msg}
)}
); }