// ────────────────────────────────────────────────────────────────── // SCUI — helpers de UI + datos compartidos por los mantenedores nuevos // (cruds-usuarios.jsx, cruds-empresas.jsx). "Misma lógica en un solo lugar": // antes cada archivo repetía read/write/nextId + los estilos + DetailShell + // JunctionMatrix. Ahora viven acá y se consumen por window.SCUI. // // Se carga ANTES de app.jsx / cruds-usuarios.jsx. `Icon` se resuelve en tiempo // de render (window.Icon lo define app.jsx), así el orden de carga no importa. // ────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────── // RENOMBRES DE TABLA — espejo de los ALTER TABLE ... RENAME TO en Postgres. // Mueve la clave completa en localStorage; sin esto el navegador seguiría // escribiendo la clave vieja, que ya no está registrada en el backend, y esa // tabla dejaría de persistir. // ────────────────────────────────────────────────────────────────── (function _migrarRenombresDeTabla() { const MOVER = [ ["sc.temporadas", "sc.contenido_temporada"], ["sc.ventana_derechos_canales_comparten_runs", "sc.contrato_compra_contenido_ventana_canal"], ["sc.temporada_ventana_derechos", "sc.contrato_compra_contenido_ventana_temporada"], ["sc.estados_aparicion", "sc.aparicion_estado"], // ── CONVENCIÓN: todo nombre de tabla en SINGULAR ── ["sc.apariciones", "sc.aparicion"], ["sc.apariciones_estado", "sc.aparicion_estado"], ["sc.archivos_media", "sc.archivo_media"], ["sc.canales_distribucion", "sc.canal_distribucion"], ["sc.capitulos_lista_de_precio", "sc.capitulo_lista_de_precio"], ["sc.clientes_autoriza_contratos", "sc.cliente_autoriza_contrato"], ["sc.clientes_credito", "sc.cliente_credito"], ["sc.contenido_temporadas", "sc.contenido_temporada"], ["sc.contenidos", "sc.contenido"], ["sc.contrato_compra_contenido_ventana_canales", "sc.contrato_compra_contenido_ventana_canal"], ["sc.documents", "sc.documento"], ["sc.producto_cta_contable", "sc.sap_cta_contable"], // Prefijo usr_ en el modelo de usuarios y menú (28_usr_prefijo.sql). El orden // no importa acá porque cada par es una clave completa, no un prefijo. ["sc.sistema", "sc.usr_sistema"], ["sc.sistema_menu_titulos", "sc.usr_sistema_menu_titulo"], ["sc.sistema_menu_items", "sc.usr_sistema_menu_item"], // Estado intermedio de 28_usr_prefijo.sql (prefijo puesto, todavía en plural): // 29_menu_singular_y_usuario.sql las pasó a singular. ["sc.usr_sistema_menu_titulos", "sc.usr_sistema_menu_titulo"], ["sc.usr_sistema_menu_items", "sc.usr_sistema_menu_item"], ["sc.modulo", "sc.usr_modulo"], ["sc.rol_modulo", "sc.usr_rol_modulo"], ["sc.rol", "sc.usr_rol"], ["sc.usuario", "sc.usr_usuario"], ["sc.usuario_rol", "sc.usr_usuario_rol"], ["sc.editores", "sc.editor"], ["sc.elem_derechos_contenido", "sc.elem_derecho_contenido"], ["sc.elem_derechos_contenido_item", "sc.elem_derecho_contenido_item"], ["sc.elem_senal_por_abonados", "sc.elem_senal_por_abonado"], ["sc.empresa_roles", "sc.empresa_rol"], ["sc.episodios", "sc.episodio"], ["sc.episodios_frankenstein", "sc.episodio_frankenstein"], ["sc.equipo_comercial_miembros", "sc.equipo_comercial_miembro"], ["sc.generos", "sc.genero"], ["sc.insumos", "sc.insumo"], ["sc.lista_de_precios", "sc.lista_de_precio"], ["sc.modulos", "sc.usr_modulo"], ["sc.oportunidades", "sc.oportunidad"], ["sc.productos", "sc.producto"], ["sc.roles", "sc.usr_rol"], ["sc.roles_modulos", "sc.usr_rol_modulo"], ["sc.stg_parrilla_apariciones", "sc.stg_parrilla_aparicion"], ["sc.user_roles", "sc.usr_usuario_rol"], ["sc.users", "sc.usr_usuario"], ]; // Tablas eliminadas del modelo: se borra la clave local. ["sc.imputacion_pep_ceco"].forEach((k) => { try { localStorage.removeItem(k); } catch (e) {} }); MOVER.forEach(([vieja, nueva]) => { try { const v = localStorage.getItem(vieja); if (v === null) return; if (localStorage.getItem(nueva) === null) localStorage.setItem(nueva, v); localStorage.removeItem(vieja); } catch (e) {} }); // CATÁLOGOS DEL MENÚ VIEJO VACIADOS (sql/30_vaciar_catalogos_modulo.sql): se // vacían también en el navegador, o el write-through devuelve a la base las // filas locales y la tabla se llena sola otra vez. Con esto queda cubierto lo // que antes hacía el filtro de módulos eliminados uno por uno. // // VA DESPUÉS DE `MOVER`: un navegador que todavía guardara la clave vieja // (sc.modulo / sc.modulos / sc.rol_modulo) la vería llegar a sc.usr_modulo // recién ahí, y vaciar antes del movimiento no serviría de nada. // // POR ÚNICA VEZ (flag): después de esto la ventana Módulos vuelve a ser dueña de // su contenido —lo que se cargue con «Sincronizar menú» o a mano se queda—, así // que no puede correr en cada carga. (function _vaciarCatalogosDeModulo() { const FLAG = "sc.migration.modulos_vacios.v1"; try { if (localStorage.getItem(FLAG) === "1") return; ["sc.usr_modulo", "sc.usr_rol_modulo"].forEach((k) => localStorage.setItem(k, "[]")); localStorage.setItem(FLAG, "1"); } catch (e) {} })(); })(); // ────────────────────────────────────────────────────────────────── // apariciones: el estado real pasó a ser FK al catálogo (id_aparicion_estado) // y el texto libre `estado` pasó a llamarse estado_transmision. // ────────────────────────────────────────────────────────────────── (function _migrarEstadoAparicion() { try { const raw = localStorage.getItem("sc.aparicion"); if (raw === null) return; const aps = JSON.parse(raw); if (!Array.isArray(aps)) return; let mut = false; aps.forEach((a) => { if (!a || typeof a !== "object") return; if (a.id_estado_aparicion !== undefined) { if (a.id_aparicion_estado === undefined) a.id_aparicion_estado = a.id_estado_aparicion; delete a.id_estado_aparicion; mut = true; } // `estado` (texto libre: En Vivo / Revisado / En Producción) pasó a llamarse // estado_transmision; el estado REAL de la parrilla es id_aparicion_estado. if (a.estado !== undefined) { if (a.estado_transmision === undefined && a.estado) a.estado_transmision = a.estado; delete a.estado; mut = true; } // …y `estado_transmision` (texto) pasó a ser la FK id_estado_transmision al // catálogo sc.estado_transmision. El texto se traduce al id del catálogo; un // valor que no esté en el catálogo se DESCARTA, porque la columna es FK y la // base rechazaría el insert. Sin esta traducción el write-through mandaría la // clave vieja y db._row_data la tiraría en silencio. if (a.estado_transmision !== undefined) { const ID_POR_NOMBRE = { "En Vivo": "ET1", "Revisado": "ET2", "En Producción": "ET3" }; const id = ID_POR_NOMBRE[String(a.estado_transmision || "").trim()]; if (a.id_estado_transmision === undefined && id) a.id_estado_transmision = id; delete a.estado_transmision; mut = true; } }); if (mut) localStorage.setItem("sc.aparicion", JSON.stringify(aps)); } catch (e) {} })(); // ────────────────────────────────────────────────────────────────── // CONVENCIÓN DE COLUMNAS: minúsculas y en español. Esta migración renombra en // localStorage las claves que quedaban en inglés (o con mayúscula), en el mismo // movimiento que el ALTER TABLE ... RENAME COLUMN ya aplicado en Postgres. // Corre PRIMERO (este archivo se carga antes que los mantenedores) porque de lo // contrario el write-through mandaría las claves viejas al backend, que las // descarta en silencio (db._row_data filtra a las columnas reales): el dato del // campo renombrado se perdería sin ningún error visible. // ────────────────────────────────────────────────────────────────── (function _migrarColumnasAEspanol() { const FLAG = "sc.migration.columnas_es.v4"; // v3: el mapa ahora usa los nombres // de tabla YA renombrados (sc.documento, sc.episodio, …). Los navegadores que // corrieron v2 con las claves viejas nunca aplicaron el mapa a las nuevas y // seguían escribiendo `name` en documento, que el backend descartaba. try { if (localStorage.getItem(FLAG) === "1") return; // clave LS -> { campo_viejo: campo_nuevo } const MAP = { "sc.archivo_media": { aspect_ratio: "relacion_aspecto", storage_url: "url_almacenamiento" }, "sc.capitulo": { list_pautas_propuesta: "lista_pautas_propuesta" }, "sc.documento": { owner_id: "id_dueno", owner_type: "tipo_dueno", name: "nombre" }, "sc.elem_pauta": { month: "mes", year: "anio" }, "sc.episodio": { is_original: "es_original", name: "nombre" }, "sc.lista_de_precio": { is_active: "es_activo" }, "sc.pauta_tv": { start: "inicio", end: "fin", value_key_param: "parametro_clave_valor" }, "sc.editor_de_pauta": { name: "nombre" }, // documento_tributario.tipo pasó a ser FK al catálogo clase_de_documento. "sc.documento_tributario": { tipo: "id_clase_de_documento" }, "sc.version_orden_venta": { Es_remesa_extranjera_bruta_con_ret: "es_remesa_extranjera_bruta_con_ret" }, "sc.version_orden_compra": { Es_remesa_extranjera_bruta_con_ret: "es_remesa_extranjera_bruta_con_ret" }, "sc.version_orden_produccion": { Es_remesa_extranjera_bruta_con_ret: "es_remesa_extranjera_bruta_con_ret" }, // ── Normalización de claves foráneas: id_ ── "sc.aparicion": { id_apariciones_estado: "id_aparicion_estado", id_archivo_video: "id_archivo_media" }, "sc.cliente_autoriza_contrato": { id_cliente_autoriza: "id_empresa_autoriza", id_cliente_autorizado: "id_empresa_autorizada" }, "sc.cliente_credito": { id_cliente: "id_empresa_cliente" }, "sc.contrato_compra_contenido": { id_propietario_contenido: "id_empresa_propietaria" }, "sc.contrato_compra_contenido_ventana_temporada": { id_temporada: "id_contenido_temporada" }, "sc.documento_tributario": { id_asiento_sap: "asiento_sap", id_cliente_facturacion: "id_empresa_facturacion" }, "sc.elem_derecho_contenido": { moneda: "id_moneda" }, "sc.producto": { id_producto_cta_contable: "id_sap_cta_contable" }, "sc.usr_sistema_menu_item": { id_sistema_menu_titulos: "id_usr_sistema_menu_titulo" }, "sc.elem_derecho_contenido_item": { id_elem_derechos_contenido: "id_elem_derecho_contenido" }, "sc.elemento_compra": { id_proveedor: "id_empresa_proveedor", id_proveedor_facturacion: "id_empresa_facturacion" }, "sc.elemento_venta": { id_cliente: "id_empresa_cliente", id_cliente_facturacion: "id_empresa_facturacion" }, "sc.empleado_monetizacion": { id_user: "id_usuario" }, "sc.empresa_rol_asignado": { id_relacion_comercial: "id_empresa_rol" }, "sc.episodio": { id_temporada: "id_contenido_temporada" }, "sc.equipo_comercial_miembro": { id_user: "id_usuario" }, // sc.usr_modulo: id_titulo_menu → clave_titulo_menu ya no se renombra — la // columna DESTINO se da de baja en la migración 107, así que esto devolvía // la fila al backend con un campo que Postgres ya no tiene. "sc.oportunidad": { id_cliente: "id_empresa_cliente", id_user: "id_usuario" }, "sc.orden_de_compra": { id_proveedor_facturacion: "id_empresa_facturacion" }, "sc.orden_de_venta": { id_cliente_facturacion: "id_empresa_facturacion" }, "sc.pauta_tv": { id_orden_produccion: "id_orden_de_produccion" }, "sc.usr_usuario_rol": { id_user: "id_usuario" }, "sc.version_orden_produccion": { id_cierre_reconocido: "id_cierre_mensual_reconocido" }, "sc.version_orden_venta": { id_cierre_reconocido: "id_cierre_mensual_reconocido" }, }; Object.keys(MAP).forEach((k) => { let rows; try { rows = JSON.parse(localStorage.getItem(k) || "null"); } catch (e) { return; } if (!Array.isArray(rows)) return; let mut = false; rows.forEach((r) => { if (!r || typeof r !== "object") return; Object.entries(MAP[k]).forEach(([viejo, nuevo]) => { if (!(viejo in r)) return; // Si el destino ya trae valor, el viejo sobra: se descarta sin sobrescribir. if (r[nuevo] === undefined || r[nuevo] === null || r[nuevo] === "") r[nuevo] = r[viejo]; delete r[viejo]; mut = true; }); }); if (mut) { try { localStorage.setItem(k, JSON.stringify(rows)); } catch (e) {} } }); // Campos ELIMINADOS del modelo: se quitan de las filas locales para que el // write-through no los mande al backend, que los descartaría en silencio. [["sc.aparicion", ["id_edicion"]]].forEach(([k, campos]) => { try { const filas = JSON.parse(localStorage.getItem(k) || "null"); if (!Array.isArray(filas)) return; let mut = false; filas.forEach((r) => campos.forEach((c) => { if (r && c in r) { delete r[c]; mut = true; } })); if (mut) localStorage.setItem(k, JSON.stringify(filas)); } catch (e) {} }); // El valor también cambia: era texto libre ("factura", "nota_credito", …) y // ahora es el id del catálogo (DA/YC/ND/DI/…). const CLASE = { factura: "DA", factura_exenta: "DE", nota_credito: "YC", nota_debito: "ND", boleta: "BV", factura_exportacion: "DI", nota_credito_exportacion: "DO", nota_debito_exportacion: "DM" }; try { const docs = JSON.parse(localStorage.getItem("sc.documento_tributario") || "null"); if (Array.isArray(docs)) { let mut = false; docs.forEach((d) => { const v = d && d.id_clase_de_documento; if (v && CLASE[v]) { d.id_clase_de_documento = CLASE[v]; mut = true; } }); if (mut) localStorage.setItem("sc.documento_tributario", JSON.stringify(docs)); } } catch (e) {} localStorage.setItem(FLAG, "1"); } catch (e) {} })(); // ────────────────────────────────────────────────────────────────── // SCApariciones — la parrilla NO se guarda en el navegador. // // sc.aparicion son 10.551 filas ≈ 1,88 MB: el 93% del bootstrap y ~75% de la // cuota de localStorage con UN año de parrilla (dos años no caben). Además, // escribirla entera para mover un bloque reescribía las 10.551 filas. // Por eso esta tabla se pide POR RANGO (canal + fechas) y se guarda POR DELTA // (sólo las filas tocadas y los ids borrados). // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const j = (r) => r.json(); window.SCApariciones = { // Trae sólo la ventana visible. Devuelve [] si el backend no está. async cargar({ canal, desde, hasta } = {}) { const q = new URLSearchParams(); if (canal) q.set("canal", canal); if (desde) q.set("desde", desde); if (hasta) q.set("hasta", hasta); try { const r = await fetch(window.__SC_BASE + "api/aparicion?" + q.toString()); if (!r.ok) throw new Error("HTTP " + r.status); const d = await j(r); return Array.isArray(d.rows) ? d.rows : []; } catch (e) { console.error("[apariciones] no se pudo cargar el rango:", e.message); return []; } }, // Guarda sólo lo que cambió. `upsert` = filas completas; `borrar` = ids. async guardar({ upsert = [], borrar = [] } = {}) { if (!upsert.length && !borrar.length) return { ok: true, upsert: 0, delete: 0 }; try { const r = await fetch(window.__SC_BASE + "api/aparicion/delta", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ upsert, delete: borrar }), }); if (!r.ok) throw new Error("HTTP " + r.status); return await j(r); } catch (e) { console.error("[apariciones] no se pudo guardar el delta:", e.message); return { ok: false, error: e.message }; } }, // Diff entre dos versiones del arreglo → {upsert, borrar} delta(antes, despues) { const A = new Map((antes || []).map((a) => [a.id, JSON.stringify(a)])); const D = new Map((despues || []).map((a) => [a.id, a])); const upsert = []; D.forEach((row, id) => { if (A.get(id) !== JSON.stringify(row)) upsert.push(row); }); const borrar = []; A.forEach((_, id) => { if (!D.has(id)) borrar.push(id); }); return { upsert, borrar }; }, }; })(); // ────────────────────────────────────────────────────────────────── // SCPropuestaTM — el elemento de venta NO cuelga de la propuesta: llega a ella // por TRES saltos, y esta es la única traducción de esos tres saltos que existe. // // propuesta ─< propuesta_incluye >─ propuesta_monetizacion >─ monetizacion // ^ // └── elemento_venta.id_propuesta_monetizacion // // Antes existía `elemento_venta.id_propuesta`; la columna se eliminó (migraciones // 28 y 29) y el enlace pasó por la tabla de unión. La migración 94 le sacó a esa // tabla su `id_propuesta` y lo mudó a sc.propuesta_incluye: el GRUPO de // monetización dejó de pertenecer a una propuesta y ahora las propuestas lo // INCLUYEN. // // ── UN GRUPO PUEDE ESTAR EN VARIAS PROPUESTAS: QUÉ HACE ESTE MÓDULO ───────── // La estructura ya no garantiza una sola respuesta a «¿de qué propuesta es este // elemento?». Hoy no hay ningún grupo incluido dos veces —la migración 94 creó // exactamente una fila de propuesta_incluye por grupo— pero nada lo impide. // // Ante varias, `propuestaDe` devuelve LA PRIMERA POR ID, que es determinista: la // misma pregunta da la misma respuesta en cada pantalla y en cada recarga. No es // «la correcta»: es la única elección estable posible sin una regla de negocio // que diga cuál manda. Si algún día un grupo se comparte de verdad, es acá —y // sólo acá— donde hay que decidir qué significa. // `propuestasDe` devuelve TODAS, para quien necesite verlas. // // Sobre la monetización: la del elemento se DERIVA de su producto // (`producto.id_monetizacion`) — `elemento_venta.id_monetizacion` estaba en desuso // y cruds-crm.jsx lo purga de las filas viejas. Por eso los grupos que existen // hoy llevan `id_monetizacion` en null y hay uno por propuesta. `idDePropuesta` // respeta eso: si algún día el par importa, se le pasa la monetización y // devuelve/crea el grupo de ese par. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const K_PM = "sc.propuesta_monetizacion"; const K_INC = "sc.propuesta_incluye"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; const escribir = (k, arr) => { try { localStorage.setItem(k, JSON.stringify(arr)); } catch (e) {} }; const nn = (v) => (v == null || v === "" ? null : v); const porId = (a, b) => String(a.id).localeCompare(String(b.id), undefined, { numeric: true }); // {id_grupo: [id_propuesta, …]}, en orden estable. Es la forma en que se lee // sc.propuesta_incluye en todos los caminos de este módulo. function inclusiones() { const m = {}; for (const f of leer(K_INC).slice().sort(porId)) { if (!f.id_propuesta_monetizacion || !f.id_propuesta) continue; (m[f.id_propuesta_monetizacion] = m[f.id_propuesta_monetizacion] || []).push(f.id_propuesta); } return m; } window.SCPropuestaTM = { // Los GRUPOS de monetización (sc.propuesta_monetizacion). Ya no dicen de qué // propuesta son: eso lo dice `incluye()`. filas: () => leer(K_PM), // Las filas de sc.propuesta_incluye, crudas. incluye: () => leer(K_INC), // id de propuesta de un elemento de venta (null si no llega a ninguna). Ante // varias, la primera por id — ver la cabecera. propuestaDe(el) { const l = window.SCPropuestaTM.propuestasDe(el); return l.length ? l[0] : null; }, // TODAS las propuestas a las que llega un elemento, en orden estable. propuestasDe(el) { if (!el || !el.id_propuesta_monetizacion) return []; return inclusiones()[el.id_propuesta_monetizacion] || []; }, // Igual, pero con el mapa ya armado: para bucles y `filter` sobre listas // grandes, donde releer localStorage por fila sería absurdo. El mapa es // {id_grupo: id_propuesta} — ya resuelto, con el mismo criterio de «la // primera por id». mapa() { const inc = inclusiones(); const m = {}; for (const g of Object.keys(inc)) m[g] = inc[g][0] || null; return m; }, propuestaDeCon(mapa, el) { return (el && el.id_propuesta_monetizacion ? mapa[el.id_propuesta_monetizacion] : null) || null; }, // Devuelve el id del GRUPO para (propuesta, monetización), creándolo —y // creando su inclusión— si no existe. Sólo se llama desde manejadores de // eventos: escribe las dos tablas, y con ellas dispara el write-through. // Con idPropuesta vacío devuelve null: un elemento «sin propuesta» no // necesita grupo, su enlace queda en null. idDePropuesta(idPropuesta, idMonetizacion) { const pp = nn(idPropuesta); if (!pp) return null; const tipo = nn(idMonetizacion); const grupos = leer(K_PM); const incs = leer(K_INC); const deLaPp = new Set(incs.filter(i => (i.id_propuesta || null) === pp) .map(i => i.id_propuesta_monetizacion)); const ya = grupos.find(g => deLaPp.has(g.id) && (g.id_monetizacion || null) === tipo); if (ya) return ya.id; // id nuevo: 12 hex como los de la carga inicial, pero sin pretender // reproducir su md5 (el navegador no lo tiene a mano y no hace falta: la // identidad la da la fila, no su forma). let id = ""; for (let i = 0; i < 12; i++) id += "0123456789abcdef"[Math.floor(Math.random() * 16)]; escribir(K_PM, grupos.concat([{ id, activo: true, id_monetizacion: tipo }])); escribir(K_INC, incs.concat([{ id: "PI_" + id, id_propuesta: pp, id_propuesta_monetizacion: id }])); return id; }, // Los GRUPOS que una propuesta incluye, en orden estable por monetización. // Son los grupos de la grilla de elementos: una propuesta declara con qué // monetizaciones se arma, y cada elemento cuelga de una de ellas. deLaPropuesta(idPropuesta) { const pp = nn(idPropuesta); if (!pp) return []; const suyos = new Set(leer(K_INC).filter(i => (i.id_propuesta || null) === pp) .map(i => i.id_propuesta_monetizacion)); return leer(K_PM) .filter(g => suyos.has(g.id)) .sort((a, b) => String(a.id_monetizacion || "￿").localeCompare(String(b.id_monetizacion || "￿"), "es", { numeric: true })); }, // Responsable de un grupo. La columna es `id_usuario` // (x_scripts/postgres_vigente/sql/40_propuesta_tipo_monetizacion_id_usuario.sql). fijarUsuario(idFila, idUsuario) { escribir(K_PM, leer(K_PM).map(g => (g.id === idFila ? { ...g, id_usuario: nn(idUsuario) } : g))); }, // Saca un grupo de monetización. SÓLO si no le cuelga ningún elemento: la // columna elemento_venta.id_propuesta_monetizacion es una FK, y borrar el // grupo con elementos vivos los dejaría apuntando a la nada — huérfanos que // ninguna ventana volvería a listar. Devuelve false y no toca nada si está en // uso, para que quien llama pueda avisar en vez de perder datos. // // Se borra el grupo Y sus inclusiones: dejar la fila de propuesta_incluye // apuntando a un grupo que ya no existe violaría su propia FK. eliminar(idFila) { let elementos = []; try { const v = JSON.parse(localStorage.getItem("sc.elemento_venta") || "[]"); if (Array.isArray(v)) elementos = v; } catch (e) {} if (elementos.some(e => e.id_propuesta_monetizacion === idFila)) return false; escribir(K_INC, leer(K_INC).filter(i => i.id_propuesta_monetizacion !== idFila)); escribir(K_PM, leer(K_PM).filter(g => g.id !== idFila)); return true; }, }; })(); // ────────────────────────────────────────────────────────────────── // SCPropuestaVersion — LAS VERSIONES, y la propuesta ACTIVA de cada oportunidad. // // El modelo (migración 97) no tiene tablas de versión: una versión es UNA FILA // MÁS de la misma tabla. // // sc.propuesta una fila por versión. Todas las de una // oportunidad comparten `id_oportunidad` y sólo UNA // tiene `activo = true` — ésa es «la propuesta» de // esa oportunidad, la que se envía y la que cuenta // el tablero. La base lo obliga con un índice único // parcial; acá no se puede violar aunque se quiera. // // sc.propuesta_monetizacion una fila por versión. Las versiones de una misma // monetización se reconocen por `id_origen`, que // apunta a la PRIMERA de la cadena. // // sc.propuesta_incluye qué monetizaciones entran en qué versión de // propuesta. Es N:N: la MISMA fila de monetización // puede estar incluida en varias versiones, que es // justo lo que permite comparar una versión con la // anterior sin duplicar lo que no cambió. // // EXCLUIR una monetización de la versión actual = borrar su renglón de // `propuesta_incluye`. No se borra la monetización: sigue existiendo y sigue // estando en las versiones anteriores que la incluían. Por eso excluir baja el // monto de la versión actual y no toca el de las viejas. // // LA PROPUESTA NO TIENE ESTADO (se fue en la migración 93). Los estados son de la // OPORTUNIDAD, y hay dos que los pone un hecho y no una persona: // · crear la PRIMERA propuesta Lead → Preparar Propuesta // · pulsar [Enviar Propuesta] Preparar Propuesta → Propuesta Enviada // El resto se eligen a mano en el kanban. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const K_PP = "sc.propuesta"; const K_PM = "sc.propuesta_monetizacion"; const K_INC = "sc.propuesta_incluye"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; const escribir = (k, arr) => { try { localStorage.setItem(k, JSON.stringify(arr)); } catch (e) {} try { setTimeout(() => { try { window.dispatchEvent(new CustomEvent("sc:ls:" + k)); } catch (e) {} }, 0); } catch (e) {} }; const hoy = () => (window.SCFecha ? window.SCFecha.hoy() : new Date().toISOString().slice(0, 10)); const hex12 = () => { let s = ""; for (let i = 0; i < 12; i++) s += "0123456789abcdef"[Math.floor(Math.random() * 16)]; return s; }; // Las versiones de una oportunidad, de la más vieja a la más nueva. const versionesDe = (idOportunidad) => leer(K_PP) .filter(p => p.id_oportunidad === idOportunidad) .sort((a, b) => (Number(a.numero_version) || 0) - (Number(b.numero_version) || 0) || String(a.id).localeCompare(String(b.id), undefined, { numeric: true })); // LA PROPUESTA EN CURSO la dice la OPORTUNIDAD: sc.oportunidad.id_propuesta_en_curso // (migración 94). Es un puntero, así que sólo puede señalar una — la regla «una // sola activa» está en la forma del dato, no en un flag que haya que vigilar. // // `propuesta.activo` NO se usa para esto: es la baja lógica de la fila, otra // cosa. Tener las dos diciendo lo mismo es tener dos verdades que se separan. const K_OP = "sc.oportunidad"; const activaDe = (idOportunidad) => { const op = leer(K_OP).find(o => o.id === idOportunidad); const enCurso = op && op.id_propuesta_en_curso; const vs = versionesDe(idOportunidad); return (enCurso && vs.find(p => p.id === enCurso)) || null; }; // Mueve el puntero de la oportunidad. Es la ÚNICA forma de cambiar cuál es la // propuesta en curso. const señalar = (idOportunidad, idPropuesta) => { escribir(K_OP, leer(K_OP).map(o => o.id === idOportunidad ? { ...o, id_propuesta_en_curso: idPropuesta } : o)); }; // Los grupos incluidos en una versión. const gruposDe = (idPropuesta) => { const suyos = new Set(leer(K_INC).filter(i => i.id_propuesta === idPropuesta) .map(i => i.id_propuesta_monetizacion)); return leer(K_PM).filter(g => suyos.has(g.id)); }; const estaIncluida = (idPropuesta, idGrupo) => leer(K_INC).some(i => i.id_propuesta === idPropuesta && i.id_propuesta_monetizacion === idGrupo); // ── Generar una nueva versión de la PROPUESTA ──────────────────── // La nueva nace incluyendo LO MISMO que la actual —comparar dos versiones sólo // tiene sentido si la nueva parte de la anterior— y se lleva el `activo`. La // anterior no se toca: queda entera, para leerla desde «Ver Versión anterior». function nuevaVersionPropuesta(idPropuesta, patch) { const props = leer(K_PP); const actual = props.find(p => p.id === idPropuesta); if (!actual) return null; const hermanas = versionesDe(actual.id_oportunidad); const numero = Math.max(0, ...hermanas.map(p => Number(p.numero_version) || 0)) + 1; const id = "PP-" + hex12(); const nueva = Object.assign({}, actual, { id, numero_version: numero, fecha_version: hoy(), referencia_version: "", fecha_creacion: hoy(), }, patch || {}); // La fila nueva se agrega y el PUNTERO de la oportunidad pasa a señalarla. Las // versiones anteriores no se tocan: siguen activas como filas (su `activo` es // la baja lógica), simplemente ya no son la que está en curso. escribir(K_PP, props.concat([nueva])); señalar(actual.id_oportunidad, id); const incs = leer(K_INC); escribir(K_INC, incs.concat( incs.filter(i => i.id_propuesta === idPropuesta) .map(i => ({ id: "PI-" + hex12(), id_propuesta: id, id_propuesta_monetizacion: i.id_propuesta_monetizacion })))); return nueva; } // Volver a dejar activa una versión anterior. No copia nada: mueve el puntero. function activarVersion(idPropuesta) { const p = leer(K_PP).find(x => x.id === idPropuesta); if (!p) return false; señalar(p.id_oportunidad, idPropuesta); return true; } // ── Versiones de una MONETIZACIÓN ──────────────────────────────── // La cadena se reconoce por `id_origen`, que apunta siempre a la primera. La // primera lo tiene en null y es su propia raíz. const raizDe = (g) => (g ? (g.id_origen || g.id) : null); const versionesDeGrupo = (idGrupo) => { const grupos = leer(K_PM); const g = grupos.find(x => x.id === idGrupo); if (!g) return []; const raiz = raizDe(g); return grupos.filter(x => raizDe(x) === raiz) .sort((a, b) => (Number(a.numero_version) || 0) - (Number(b.numero_version) || 0)); }; // «Nueva» arranca en blanco; «Duplicar» copia la actual. Las dos crean una fila // y hacen que la versión de propuesta ACTUAL pase a incluir la nueva en lugar // de la vieja — la vieja se queda en las versiones anteriores que la tenían. function nuevaVersionGrupo(idPropuesta, idGrupo, opciones) { const duplicar = !!(opciones && opciones.duplicar); const grupos = leer(K_PM); const actual = grupos.find(g => g.id === idGrupo); if (!actual) return null; const cadena = versionesDeGrupo(idGrupo); const numero = Math.max(0, ...cadena.map(g => Number(g.numero_version) || 0)) + 1; const id = hex12(); const base = duplicar ? Object.assign({}, actual) : { id_monetizacion: actual.id_monetizacion, id_usuario: actual.id_usuario, }; const nueva = Object.assign({}, base, { id, activo: true, numero_version: numero, fecha_version: hoy(), referencia_version: "", id_origen: raizDe(actual), }); escribir(K_PM, grupos.concat([nueva])); // La versión de propuesta actual cambia de fila: deja de incluir la vieja. const incs = leer(K_INC); escribir(K_INC, incs .filter(i => !(i.id_propuesta === idPropuesta && i.id_propuesta_monetizacion === idGrupo)) .concat([{ id: "PI-" + hex12(), id_propuesta: idPropuesta, id_propuesta_monetizacion: id }])); return nueva; } // ── Excluir / incluir en la versión ACTUAL ─────────────────────── // Excluir saca la monetización de ESTA versión, ya: se va del monto y de lo que // se manda al cliente. No borra la monetización ni la saca de las versiones // anteriores. function excluirDeVersion(idPropuesta, idGrupo) { escribir(K_INC, leer(K_INC).filter( i => !(i.id_propuesta === idPropuesta && i.id_propuesta_monetizacion === idGrupo))); } function incluirEnVersion(idPropuesta, idGrupo) { if (estaIncluida(idPropuesta, idGrupo)) return; escribir(K_INC, leer(K_INC).concat( [{ id: "PI-" + hex12(), id_propuesta: idPropuesta, id_propuesta_monetizacion: idGrupo }])); } // ── Los dos estados de oportunidad que pone un HECHO ───────────── const E_LEAD = "Lead", E_PREP = "Preparar Propuesta", E_ENVIADA = "Propuesta Enviada"; // Crear la PRIMERA propuesta de una oportunidad la saca de «Lead». Desde // cualquier otro estado no se toca: una oportunidad que ya está negociando no // vuelve a «Preparar Propuesta» porque se agregó una versión. function alCrearPropuesta(idOportunidad) { if (!idOportunidad) return { movida: false }; try { if (window.SCGanada.estadoDe(idOportunidad) !== E_LEAD) return { movida: false }; window.SCGanada.cambiarEstadoOportunidad(idOportunidad, E_PREP); return { movida: true, desde: E_LEAD, hasta: E_PREP }; } catch (e) { return { movida: false }; } } // [Enviar Propuesta]: de «Preparar Propuesta» a «Propuesta Enviada». Desde // cualquier otro estado el envío no mueve nada — los demás son posteriores, y // retroceder borraría un avance que nadie deshizo. function enviar(idOportunidad) { if (!idOportunidad) return { movida: false, desde: null }; let desde = null; try { desde = window.SCGanada.estadoDe(idOportunidad); } catch (e) {} if (desde !== E_PREP) return { movida: false, desde }; try { window.SCGanada.cambiarEstadoOportunidad(idOportunidad, E_ENVIADA); } catch (e) {} return { movida: true, desde, hasta: E_ENVIADA }; } window.SCPropuestaVersion = { E_LEAD, E_PREP, E_ENVIADA, versionesDe, activaDe, señalar, gruposDe, estaIncluida, nuevaVersionPropuesta, activarVersion, versionesDeGrupo, nuevaVersionGrupo, excluirDeVersion, incluirEnVersion, alCrearPropuesta, enviar, }; })(); // ────────────────────────────────────────────────────────────────── // SCFecha — TODA fecha de la aplicación se lee y se escribe en YYYY-MM-DD. // // Es el mismo formato en que las fechas ya viajan y se guardan (ISO), así que // mostrarlas de otra manera obligaba a traducir con la vista puesta: la grilla // decía «09 ago. 2026», el campo de al lado decía «09/08/2026» y la base decía // «2026-08-09». Tres escrituras del mismo día. // // El campo NO puede ser : el navegador dibuja esa caja con // el formato de SU idioma (dd/mm/aaaa en es-CL) y no hay atributo ni CSS que lo // cambie. Por eso `SCFecha.Input` escribe la fecha en una caja de texto normal, // en ISO, que es lo que se quiere ver. // // ── EL CALENDARIO TAMBIÉN ES NUESTRO ──────────────────────────────────────── // Antes el botón de la derecha abría el calendario DEL NAVEGADOR: había un // escondido de 1×1 y se le pedía showPicker(). Se sacó // porque ese calendario NO SE CERRABA al elegir el día: quedaba flotando sobre // la ficha y no había manera de bajarlo desde la página. Y no la había de // verdad: el popup es interfaz del navegador, no un nodo del documento — la // página no puede saber si está abierto ni pedirle que se cierre, así que // tampoco se puede verificar el arreglo. Se midió lo que sí se podía medir // (React no le reescribe el `value` al elegir un día: cero escrituras) y con eso // quedó descartada la explicación de siempre. // // El calendario de acá abajo se dibuja en el DOM, y por eso su ciclo de vida es // del componente: se cierra al elegir un día, con Escape, al hacer clic afuera, // al hacer scroll y al desmontarse. Se puede abrir y cerrar en una prueba. // // Va en un portal a con `position: fixed`: dentro del campo quedaba // recortado por el primer ancestro con overflow (el cuerpo de cualquier drawer // que scrollea). Por eso también se cierra al hacer scroll — si no, quedaría // pegado en la pantalla mientras la ficha se mueve debajo. // ────────────────────────────────────────────────────────────────── (function () { const ISO_RE = /^\d{4}-\d{2}-\d{2}$/; const MESES = ["ene.", "feb.", "mar.", "abr.", "may.", "jun.", "jul.", "ago.", "sep.", "oct.", "nov.", "dic."]; const DIAS = ["L", "M", "M", "J", "V", "S", "D"]; // Sólo para MOSTRAR. Acepta ISO con o sin hora y devuelve el día en ISO; lo // que no sea una fecha reconocible se devuelve tal cual (mejor el dato crudo // a la vista que un «—» que lo esconde). function fmt(iso) { if (!iso) return "—"; const s = String(iso); if (ISO_RE.test(s)) return s; if (ISO_RE.test(s.slice(0, 10))) return s.slice(0, 10); const d = new Date(s); return isNaN(d) ? s : d.toISOString().slice(0, 10); } // Fecha de HOY en ISO local (no UTC: `toISOString` sobre la medianoche local // adelanta un día en Chile). function hoy() { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } // ── Aritmética de calendario, sin pasar por el parseo de cadenas de Date ──── // `new Date("2026-08-13")` se interpreta en UTC y en Chile devuelve el día // anterior; `new Date(a, m, d)` es hora local y no tiene ese problema. const partes = (iso) => { const p = iso.split("-").map(Number); return { a: p[0], m: p[1] }; }; const armar = (a, m, d) => `${a}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const diasDelMes = (a, m) => new Date(a, m, 0).getDate(); // m es 1-12 const primerDia = (a, m) => (new Date(a, m - 1, 1).getDay() + 6) % 7; // lunes = 0 // El calendario. `valor`, `min` y `max` son ISO, y en ISO la comparación de // texto ES la comparación de fechas: no hace falta convertir nada. function Calendario({ valor, min, max, caja, onElegir, onCerrar }) { const hoyISO = hoy(); const [mes, setMes] = React.useState(() => partes(ISO_RE.test(valor) ? valor : hoyISO)); // Dónde se dibuja: debajo de la caja, alineado a su izquierda, y arriba si no // cabe abajo. Se calcula una vez, al abrir — cualquier scroll lo cierra. const sitio = React.useMemo(() => { const r = caja && caja.getBoundingClientRect ? caja.getBoundingClientRect() : { left: 0, bottom: 0, top: 0 }; const alto = 250, cabe = r.bottom + alto <= (window.innerHeight || 800); return { left: Math.max(4, Math.min(r.left, (window.innerWidth || 1024) - 226)), top: cabe ? r.bottom + 4 : Math.max(4, r.top - alto - 4) }; }, [caja]); const mover = (n) => setMes(({ a, m }) => { const t = m + n; return t < 1 ? { a: a - 1, m: 12 } : t > 12 ? { a: a + 1, m: 1 } : { a, m: t }; }); const fuera = (v) => (!!min && v < min) || (!!max && v > max); const celdas = []; for (let i = 0; i < primerDia(mes.a, mes.m); i++) celdas.push(null); for (let d = 1; d <= diasDelMes(mes.a, mes.m); d++) celdas.push(armar(mes.a, mes.m, d)); return (
e.stopPropagation()}>
{MESES[mes.m - 1]} {mes.a}
{DIAS.map((d, i) => {d})} {celdas.map((v, i) => v === null ? : )}
); } // Campo de fecha en ISO. Reemplaza a en toda la app y // conserva su contrato: `value` e `onChange(e)` con `e.target.value` en ISO // (o "" cuando se vacía), y `className`/`style` siguen vistiendo LA CAJA que // se escribe — que es lo que los llamadores creían estar vistiendo. El // envoltorio (posiciona el botón del calendario) sólo se toca con // `wrapperStyle`, y sólo hace falta cuando el campo es un ítem flex y hay que // decirle cuánto ancho ocupa. function Input({ value, onChange, className, style, wrapperStyle, title, disabled, min, max, placeholder, ...rest }) { const [txt, setTxt] = React.useState(value || ""); const [abierto, setAbierto] = React.useState(false); const envoltorio = React.useRef(null); // El texto sigue al valor de afuera salvo mientras se está tecleando algo // que todavía no es una fecha: si no, borrar un dígito lo devolvería. React.useEffect(() => { setTxt(value || ""); }, [value]); // Cerrar el calendario: Escape, clic afuera del campo, o scroll. El clic se // escucha en fase de captura y se compara contra el ENVOLTORIO —no contra el // popup— para que el botón que lo abre no se cuente como "afuera" (si no, // cerraría en el mousedown y volvería a abrir en el click, y no se podría // cerrar nunca desde el botón). React.useEffect(() => { if (!abierto) return; const cerrar = () => setAbierto(false); const teclado = (e) => { if (e.key === "Escape") cerrar(); }; const afuera = (e) => { const w = envoltorio.current; if (w && !w.contains(e.target)) cerrar(); }; document.addEventListener("keydown", teclado, true); document.addEventListener("mousedown", afuera, true); window.addEventListener("scroll", cerrar, true); window.addEventListener("resize", cerrar); return () => { document.removeEventListener("keydown", teclado, true); document.removeEventListener("mousedown", afuera, true); window.removeEventListener("scroll", cerrar, true); window.removeEventListener("resize", cerrar); }; }, [abierto]); // Un campo que se deshabilita con el calendario abierto no lo deja colgado. React.useEffect(() => { if (disabled) setAbierto(false); }, [disabled]); const emitir = (v) => onChange && onChange({ target: { value: v } }); const confirmar = () => { const v = txt.trim(); if (v === "") { emitir(""); return; } if (ISO_RE.test(v) && !isNaN(new Date(v + "T00:00:00"))) { emitir(v); return; } setTxt(value || ""); // basura: se descarta y vuelve lo que había }; const calendario = abierto && !disabled ? ( setAbierto(false)} onElegir={(v) => { setTxt(v); emitir(v); setAbierto(false); }}/> ) : null; return ( {/* `paddingRight` va DESPUÉS del style del llamador: varios traen su propio `padding` y, sin esto, el texto se metería debajo del botón del calendario. */} setTxt(e.target.value)} onBlur={confirmar} onKeyDown={(e) => { if (e.key === "Enter") { confirmar(); e.currentTarget.blur(); } }} {...rest} /> {/* Al , para que no lo recorte el overflow de ningún contenedor. Sin ReactDOM (el render del lado del servidor de los checks) se dibuja en su lugar: el calendario nace cerrado, así que ahí nunca aparece. */} {calendario && (window.ReactDOM && window.ReactDOM.createPortal ? window.ReactDOM.createPortal(calendario, document.body) : calendario)} ); } window.SCFecha = { fmt, hoy, ISO_RE, Input }; })(); // ────────────────────────────────────────────────────────────────── // SCPropuesta — lo que la propuesta dejó de guardar en columnas. // // sc.propuesta perdió `valor_total_neto`, `moneda`, `tolerancia_descuadre`, // `tipos_invitados` y `fecha_envio` (migración 37) y, más tarde, `estado` // (migración 93). Lo que esas columnas respondían se sigue necesitando en // pantalla, sólo que ahora se DERIVA de los elementos de venta de la propuesta: // // ¿cuánto vale? Σ elemento_venta.valor_neto_moneda_original // ¿qué zonas participan? el producto.id_monetizacion de cada elemento // // Las dos derivaciones viven acá una sola vez: las cinco ventanas que las // muestran (CRM, pipeline de elementos, generar órdenes, señal y derechos) las // piden en vez de recalcularlas cada una a su manera. // // La moneda ya no se mezcla: cada elemento trae la suya (`id_moneda`) y la suma // es aritmética simple sobre el valor en moneda original, igual que la que ya // hacía el drilldown de la propuesta. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; // Σ de una lista de elementos de venta ya filtrada (la que varias ventanas // tienen a mano agrupada por propuesta). const suma = (elementos) => (elementos || []).reduce((a, el) => a + (Number(el.valor_neto_moneda_original) || 0), 0); // {id_propuesta: Σ valor de sus elementos}. `elementos` es opcional: sin él se // lee sc.elemento_venta de localStorage. function valores(elementos) { const els = elementos || leer("sc.elemento_venta"); const ptm = window.SCPropuestaTM.mapa(); const m = {}; for (const el of els) { const pp = window.SCPropuestaTM.propuestaDeCon(ptm, el); if (pp) m[pp] = (m[pp] || 0) + (Number(el.valor_neto_moneda_original) || 0); } return m; } const valor = (idPropuesta, elementos) => (valores(elementos)[idPropuesta] || 0); // {id_propuesta: [id_monetizacion, …]} — las zonas que aportan elementos. // `productoById` es opcional (se arma leyendo sc.producto). function zonas(elementos, productoById) { const els = elementos || leer("sc.elemento_venta"); const pb = productoById || Object.fromEntries(leer("sc.producto").map(p => [p.id, p])); const ptm = window.SCPropuestaTM.mapa(); const m = {}; for (const el of els) { const pp = window.SCPropuestaTM.propuestaDeCon(ptm, el); const z = pp && pb[el.id_producto] ? pb[el.id_producto].id_monetizacion : null; if (!z) continue; m[pp] = m[pp] || []; if (!m[pp].includes(z)) m[pp].push(z); } return m; } window.SCPropuesta = { suma, valores, valor, zonas }; })(); // ────────────────────────────────────────────────────────────────── // SCAlcance — QUÉ OPORTUNIDADES VE CADA PERSONA, en un solo lugar. // // Una oportunidad entra en el alcance de alguien si alguno de SUS tipos de // monetización está entre los que esa persona tiene habilitados en // sc.empleado_monetizacion (la tabla que mantienen «Tipo monetización → // usuarios» y su espejo). // // Los tipos de una oportunidad salen HOY de un solo lado: // // propuestas propuesta ─< propuesta_tipo_monetizacion.id_monetizacion // // El otro aporte —`oportunidad.id_monetizacion_origen`— se dio de baja con la // columna en la migración 104. Por eso una oportunidad recién creada no tiene // ningún tipo: ver `alcanza`, que es donde se decide qué pasa con ella. // // Vive acá y no en una ventana porque son DOS las que muestran las mismas // oportunidades —«CRM-propuestas» y «CRM-Definir Productos»— y dos copias de la // regla se separan en cuanto alguien toca una: la misma persona vería una // oportunidad en una pantalla y no en la otra, sin nada que lo explique. // // No es un filtro que la pantalla pueda apagar: no hay «ver todas». Lo que se // elige en pantalla (una zona, un estado) filtra DENTRO de esto, nunca fuera. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const K_ETM = "sc.empleado_monetizacion"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; // Quién entró. Sin sesión resuelta no hay alcance, y sin alcance no se ve nada: // es lo correcto: es preferible una pantalla vacía que se explica a una que // muestra el pipeline de otro equipo. const usuarioActual = () => { try { return (window.SCAuth && window.SCAuth.currentUserId()) || null; } catch (e) { return null; } }; // Los tipos habilitados de una persona. La tabla no tiene baja lógica: una // fila existe o no existe. const tiposDe = (idUsuario) => new Set( leer(K_ETM).filter(h => h.id_usuario === idUsuario && h.id_monetizacion) .map(h => h.id_monetizacion)); // {id_oportunidad: Set(tipos)}. `propuestas` y `oportunidades` se pasan desde // la ventana —que ya las tiene en sus hooks y se entera cuando cambian—; las // tablas del enlace se leen acá, que son las que ninguna ventana observa. // // Son DOS saltos desde la migración 94: la propuesta ya no llega a su // monetización por una sola fila —el grupo dejó de declarar id_propuesta— sino // por propuesta_incluye ─ propuesta_monetizacion. const tiposPorOportunidad = (oportunidades, propuestas) => { const tipoDelGrupo = {}; window.SCPropuestaTM.filas().forEach(g => { if (g.id_monetizacion) tipoDelGrupo[g.id] = g.id_monetizacion; }); const tiposDePropuesta = {}; window.SCPropuestaTM.incluye().forEach(i => { const t = tipoDelGrupo[i.id_propuesta_monetizacion]; if (!i.id_propuesta || !t) return; (tiposDePropuesta[i.id_propuesta] = tiposDePropuesta[i.id_propuesta] || []).push(t); }); const m = {}; (oportunidades || []).forEach(o => { // Sin oportunidad.id_monetizacion_origen (migración 104) el único aporte // viene de las propuestas: una oportunidad sin propuesta con tipo no // pertenece a ninguno. m[o.id] = new Set(); }); (propuestas || []).forEach(p => { const s = m[p.id_oportunidad]; if (s) (tiposDePropuesta[p.id] || []).forEach(t => s.add(t)); }); return m; }; // ¿Esta oportunidad entra? `tipos` es el Set de la persona y `porOp` el mapa // de arriba: los dos se calculan una vez por render y se pasan, para no // rearmarlos por fila. // Una oportunidad SIN NINGÚN TIPO todavía la ve SU RESPONSABLE, y nadie más. // // Antes no la veía nadie: al caer `id_monetizacion_origen` (migración 104) el // único aporte quedó del lado de las propuestas, así que una oportunidad // recién creada —sin propuesta con tipo— salía de todos los tableros. La 104 // lo anotó como consecuencia aceptada, y lo era mientras el drawer del CRM // dejara cargarle productos ahí mismo; con el drawer dado de baja ya no hay // desde dónde darle un tipo, así que crear una oportunidad la hacía // desaparecer para siempre. // // Se abre por el responsable y no para todos porque una oportunidad sin tipo // no es de ningún equipo: mostrarla en el tablero de los demás sería llenarlos // de borradores ajenos, que es justo lo que el alcance evita. const alcanza = (tipos, porOp, op) => { if (!tipos || tipos.size === 0 || !op) return false; const s = porOp[op.id]; if (!s) return false; if (s.size === 0) return !!op.id_usuario && op.id_usuario === usuarioActual(); for (const t of s) if (tipos.has(t)) return true; return false; }; window.SCAlcance = { usuarioActual, tiposDe, tiposPorOportunidad, alcanza }; })(); // ────────────────────────────────────────────────────────────────── // SCRrhh — DE QUÉ DIRECCIÓN ES UNA PERSONA, en un solo lugar. // // La estructura RRHH tiene tres niveles y la persona cuelga del MÁS FINO: // // rrhh_estructura_direccion // └─< rrhh_estructura_gerencia_area (id_rrhh_estructura_direccion) // └─< rrhh_estructura_sub_gerencia_area (id_rrhh_estructura_gerencia_area) // └── usr_usuario.id_rrhh_estructura_sub_gerencia_area // // Así que «la dirección de esta persona» son TRES saltos hacia arriba, y ninguno // de ellos es una columna del usuario. Los combos de las ventanas de selección de // personas filtraban por GERENCIA —dos saltos— y cada una llevaba su propia copia // del mapa: siete copias de la misma travesía, que es exactamente como dos // pantallas terminan filtrando distinto sin que nadie lo note. // // Vive acá por el mismo motivo que SCPropuestaTM: la travesía se escribe UNA vez. // // NO se cachea: las tres tablas se leen en cada llamada. Son catálogos de decenas // de filas, las ventanas ya envuelven el resultado en un React.useMemo, y un caché // acá se quedaría viejo en cuanto alguien edita la estructura en su propia ventana. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const K_DIR = "sc.rrhh_estructura_direccion"; const K_GER = "sc.rrhh_estructura_gerencia_area"; const K_SGA = "sc.rrhh_estructura_sub_gerencia_area"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; const cmp = (a, b) => String(a || "").localeCompare(String(b || ""), "es", { sensitivity: "base" }); // Las direcciones para el combo: sólo las ACTIVAS y ordenadas por nombre. Una // dada de baja no se ofrece — no hay a quién filtrar con ella. const direcciones = () => leer(K_DIR).filter(d => d.activo !== false) .sort((a, b) => cmp(a.nombre, b.nombre)); // {id_direccion: nombre}, incluidas las de baja: una persona puede seguir // colgando de una rama dada de baja y su nombre tiene que poder mostrarse. const nombreDireccion = () => Object.fromEntries(leer(K_DIR).map(d => [d.id, d.nombre || d.id])); // {id_usuario: id_direccion | null} — los tres saltos. Queda en null quien no // tenga sub-gerencia, o cuya rama esté rota en cualquiera de los dos escalones: // eso NO es un error a esconder, es lo que el combo ofrece como «— sin dirección —». // // `usuarios` es opcional: sin él se lee sc.usr_usuario. Se puede pasar la lista // que la ventana ya tiene en su hook para no releerla. function direccionDeUsuario(usuarios) { const dirPorGer = {}; for (const g of leer(K_GER)) dirPorGer[g.id] = g.id_rrhh_estructura_direccion || null; const dirPorSga = {}; for (const s of leer(K_SGA)) dirPorSga[s.id] = dirPorGer[s.id_rrhh_estructura_gerencia_area] || null; const m = {}; for (const u of (usuarios || leer("sc.usr_usuario"))) { m[u.id] = dirPorSga[u.id_rrhh_estructura_sub_gerencia_area] || null; } return m; } window.SCRrhh = { direcciones, nombreDireccion, direccionDeUsuario }; })(); // ────────────────────────────────────────────────────────────────── // SCCaracteristica — DE DÓNDE SALEN LOS VALORES DE UNA CARACTERÍSTICA. // // Un producto declara, por cada una de sus dos casillas, un TIPO // (sc.producto.tipo_caracteristica_principal / _secundaria, migración 100): // // lista la lista la escribe el usuario, producto por producto // target sistemas de medición de audiencia // medio medios de distribución // inventario catálogo de inventario // // Y cada tipo contesta DOS preguntas distintas, que es lo que confunde si no se // separan: // // 1. ¿cómo se LLAMA la característica? → `nombres(tipo)` // Es lo que se guarda en producto.nombre_caracteristica_*. Con «lista» lo // escribe quien edita; con los otros tres se ELIGE de un catálogo. // // 2. ¿entre qué VALORES elige el elemento de venta? → `opciones(tipo, nombre, …)` // Es lo que termina en elemento_venta.id_producto_caracteristica_* — y el id // que se guarda YA NO es siempre de producto_caracteristica: es de la tabla // que corresponda al tipo. // // EN LOS TRES TIPOS DE CATÁLOGO NO SE ESCRIBE NI UNA FILA EN // sc.producto_caracteristica. El catálogo ya existe en otra tabla; copiarlo sería // la misma verdad contada dos veces, y se separarían en cuanto alguien edite una. // // ── CÓMO FILTRA CADA UNO ──────────────────────────────────────────────────── // target audiencia_target_sistema_medicion.nombre → audiencia_target // por id_audiencia_target_sistema_medicion (FK declarada) // medio medio.nombre → canal_distribucion // por id_medio (FK declarada) // inventario inventario.nombre → inventario_grupo // por inventario_grupo.tipo (SIN FK declarada) // // OJO CON «inventario», QUE ES EL RARO: sc.inventario_grupo (id · nombre · tipo) // NO tiene ninguna FK hacia sc.inventario — a diferencia de los otros dos pares, // donde el hijo apunta al padre con una restricción de verdad. La única columna // que puede decir a qué inventario pertenece un grupo es `tipo`, así que el // filtro se hace comparando `inventario_grupo.tipo` con el nombre elegido. Es una // SUPOSICIÓN, y está escrita acá —en un solo lugar— para que corregirla sea // cambiar una línea. Hoy las dos tablas están vacías, así que no hay dato que la // contradiga ni que la confirme. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; const vivo = (r) => r.activo !== false; const cmp = (a, b) => String(a || "").localeCompare(String(b || ""), "es", { sensitivity: "base" }); // Los cuatro valores del CHECK ck_producto_tipo_caracteristica_* (migración 100). // Si acá se agregara uno que la base no acepta, el guardado fallaría con un 409. const TIPOS = ["lista", "medio", "inventario", "target"]; const ETIQUETA = { lista: "Lista propia", medio: "Medio", inventario: "Inventario", target: "Target de audiencia", }; // La definición de los tres tipos de CATÁLOGO. `lista` no está acá a propósito: // no tiene catálogo de origen, sus valores son filas de producto_caracteristica. const ORIGEN = { target: { catalogo: "sc.audiencia_target_sistema_medicion", campoNombre: "nombre", hijo: "sc.audiencia_target", campoHijoNombre: "nombre", // El hijo apunta al padre por FK declarada. liga: (hijo, padre) => hijo.id_audiencia_target_sistema_medicion === padre.id, }, medio: { catalogo: "sc.medio", campoNombre: "nombre", hijo: "sc.canal_distribucion", campoHijoNombre: "nombre_canal", liga: (hijo, padre) => hijo.id_medio === padre.id, }, inventario: { catalogo: "sc.inventario", campoNombre: "nombre", hijo: "sc.inventario_grupo", campoHijoNombre: "nombre", // SIN FK: se liga por nombre contra `tipo`. Ver la nota de la cabecera. liga: (hijo, padre) => String(hijo.tipo || "").trim().toLowerCase() === String(padre.nombre || "").trim().toLowerCase(), }, }; // Los NOMBRES que puede tomar la característica de un tipo de catálogo, en // orden. Para «lista» devuelve [] : ahí el nombre se escribe, no se elige. function nombres(tipo) { const o = ORIGEN[tipo]; if (!o) return []; return leer(o.catalogo).filter(vivo) .map(r => r[o.campoNombre]).filter(Boolean) .sort(cmp); } // Las OPCIONES entre las que elige el elemento de venta: [{id, nombre}]. El `id` // es EXACTAMENTE lo que va a elemento_venta.id_producto_caracteristica_*. // // lista las filas de producto_caracteristica de ese producto y casilla // los otros las filas del hijo que cuelgan del catálogo llamado `nombre` function opciones(tipo, nombre, opts) { const { idProducto, esPrincipal } = opts || {}; if (tipo === "lista") { return leer("sc.producto_caracteristica") .filter(c => c.id_producto === idProducto && !!c.es_principal === !!esPrincipal && vivo(c)) .map(c => ({ id: c.id, nombre: c.nombre })) .sort((a, b) => cmp(a.nombre, b.nombre)); } const o = ORIGEN[tipo]; if (!o || !nombre) return []; // Puede haber más de una fila del catálogo con el mismo nombre; se aceptan // todas sus hijas en vez de quedarse con la primera y esconder el resto. const padres = leer(o.catalogo).filter(vivo) .filter(r => String(r[o.campoNombre] || "").trim() === String(nombre).trim()); if (!padres.length) return []; return leer(o.hijo).filter(vivo) .filter(h => padres.some(p => o.liga(h, p))) .map(h => ({ id: h.id, nombre: h[o.campoHijoNombre] || h.nombre || h.id })) .sort((a, b) => cmp(a.nombre, b.nombre)); } // ¿Este tipo guarda sus valores en sc.producto_caracteristica? Sólo «lista». const escribeCaracteristicas = (tipo) => tipo === "lista"; // De qué tabla sale el id que el elemento termina guardando — para poder decirlo // en pantalla en vez de que el usuario lo adivine. const tablaDelId = (tipo) => (tipo === "lista" ? "sc.producto_caracteristica" : (ORIGEN[tipo] || {}).hijo || null); window.SCCaracteristica = { TIPOS, ETIQUETA, ORIGEN, nombres, opciones, escribeCaracteristicas, tablaDelId }; })(); // ────────────────────────────────────────────────────────────────── // SCGanada — el estado de la OPORTUNIDAD, en un solo lugar. // // Escribe sc.estado_oportunidad —que es un HISTORIAL: el estado actual es la // última fila por fecha de entrada— y avisa con el evento `sc:ls:`, que // es lo que escuchan los hooks de las ventanas (_useLS en cruds-crm.jsx): por // eso una pantalla ve el cambio que hizo la otra sin recargar. // // ── LO QUE ESTE MÓDULO ERA Y YA NO ES ─────────────────────────────── // Se llamaba así porque sostenía la regla de adjudicación: // // oportunidad en «Ganada» ⟺ EXACTAMENTE UNA de sus propuestas en «ganada» // // Esa equivalencia se apoyaba entera en `sc.propuesta.estado`, columna que se // dio de baja en la migración 93. Sin un estado por propuesta no hay ganadora // que elegir, así que se fueron con ella `cambiarEstadoPropuesta`, el swap de // ganadora, el rechazo al desadjudicar, `inconsistencias()` y el avance // automático por envío (`avanzarPorEnvio`, que era del botón [Enviar propuesta]). // La oportunidad conserva su propia máquina de estados —CRM_TRANSICIONES en // core/cruds-crm.jsx—, que nunca dependió de la propuesta. // // EL NOMBRE SE CONSERVA A PROPÓSITO: renombrarlo tocaría cuatro ventanas por una // razón cosmética, y la migración que lo vació ya está explicada acá arriba. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const K_EST = "sc.estado_oportunidad"; const leer = (k) => { try { const v = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(v) ? v : []; } catch (e) { return []; } }; const escribir = (k, arr) => { try { localStorage.setItem(k, JSON.stringify(arr)); } catch (e) {} try { setTimeout(() => { try { window.dispatchEvent(new CustomEvent("sc:ls:" + k)); } catch (e) {} }, 0); } catch (e) {} }; const hoy = () => new Date().toISOString().slice(0, 10); // Estado actual de una oportunidad = la última fila por fecha de entrada, con // el mismo criterio que usa el kanban (orden estable por fecha y luego por id, // porque la fecha es de día y dos cambios del mismo día empatan). function estadoDe(idOportunidad, estados) { const l = (estados || leer(K_EST)).filter(e => e.id_oportunidad === idOportunidad) .sort((a, b) => String(a.fecha_entrada_al_estado || "").localeCompare(String(b.fecha_entrada_al_estado || "")) || String(a.id).localeCompare(String(b.id), undefined, { numeric: true })); return l.length ? l[l.length - 1].estado : null; } function nuevoIdEstado(estados) { const max = estados.reduce((m, e) => Math.max(m, parseInt(String(e.id).replace(/\D/g, ""), 10) || 0), 0); return "ES" + (max + 1); } // Agrega la fila de estado de la oportunidad si de verdad cambia. function moverOportunidad(idOportunidad, estado, estados) { if (estadoDe(idOportunidad, estados) === estado) return estados; return estados.concat([{ id: nuevoIdEstado(estados), id_oportunidad: idOportunidad, estado: estado, fecha_entrada_al_estado: hoy(), }]); } // ── Cambiar el estado de UNA oportunidad ──────────────────────── // Sigue devolviendo {ok:true} / {ok:false, error} aunque hoy no tenga ningún // motivo para rechazar: quien llama ya sabe leer esa forma, y la validación de // la tabla de transiciones vive en la ventana (CRM_TRANSICIONES). function cambiarEstadoOportunidad(idOportunidad, nuevoEstado) { const estados = leer(K_EST); if (estadoDe(idOportunidad, estados) === nuevoEstado) return { ok: true, sinCambio: true }; escribir(K_EST, moverOportunidad(idOportunidad, nuevoEstado, estados)); return { ok: true }; } // ── AUTOMÁTICO: tocar una propuesta ya enviada la pone «En Modificación» ── // Se llama desde donde se edita la propuesta, sus tipos de monetización o sus // elementos. Sólo mueve desde «Propuesta Enviada»: desde cualquier otro estado // no hay nada que anunciar, y desde Ganada/Perdida/Cancelada un retoque no // reabre el trato. function marcarModificacion(idOportunidad) { if (!idOportunidad) return { ok: true, sinCambio: true }; if (estadoDe(idOportunidad) !== "Propuesta Enviada") return { ok: true, sinCambio: true }; escribir(K_EST, moverOportunidad(idOportunidad, "En Modificación", leer(K_EST))); return { ok: true }; } window.SCGanada = { estadoDe, cambiarEstadoOportunidad, marcarModificacion }; })(); // ────────────────────────────────────────────────────────────────── // SCPersist.useWriteOnChange — persiste una tabla SÓLO cuando cambió de verdad. // // El patrón `useEffect(() => write(rows), [rows])` también corre en el MONTAJE, y // como el estado inicial acaba de leerse de localStorage, esa primera escritura no // cambia nada… pero dispara el write-through (PUT /api/table/) y con él una // reescritura COMPLETA de la tabla en Postgres. Es decir: abrir una ventana // reescribía su tabla entera. Con 10.551 apariciones eso era además el gatillo del // truncamiento. Este hook salta la primera corrida. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; window.SCPersist = { useWriteOnChange(rows, write, deps) { const primera = React.useRef(true); React.useEffect(() => { if (primera.current) { primera.current = false; return; } try { write(rows); } catch (e) {} }, deps || [rows]); }, }; })(); // ────────────────────────────────────────────────────────────────── // Migración de campo: sc.usuario.id_depto_rrhh -> id_rrhh_estructura_sub_gerencia_area // (la estructura RRHH pasó a tres niveles; ver x_scripts/postgres_vigente/sql/10_rrhh_estructura.sql). // Hace falta porque `db.py._row_data` filtra toda clave que no sea columna real: una // fila que se quede con el nombre viejo pierde el valor en el próximo PUT, sin error. // El valor NO se arrastra: apuntaba a sc.depto_rrhh, que no es el nivel nuevo. // Idempotente y gated por flag: corre una vez por navegador. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; var FLAG = "sc.migration.rrhh_estructura"; try { if (localStorage.getItem(FLAG) === "1") return; var filas = JSON.parse(localStorage.getItem("sc.usr_usuario") || "null"); if (Array.isArray(filas)) { var mut = false; filas.forEach(function (u) { if (u && Object.prototype.hasOwnProperty.call(u, "id_depto_rrhh")) { delete u.id_depto_rrhh; mut = true; } }); if (mut) localStorage.setItem("sc.usr_usuario", JSON.stringify(filas)); } localStorage.setItem(FLAG, "1"); } catch (e) {} })(); // ────────────────────────────────────────────────────────────────── // SCSap — vocabulario común de las cuatro ventanas de alcance SAP // (cruds-sap-alcance.jsx): nombres de tabla, etiquetas y las dos dimensiones. // // Las cuatro escriben UNA sola tabla, sc.sap_pep_ceco: una fila = una persona // alcanza un PEP (temporada / programa) o un CECO, y `es_pep` dice cuál de las dos // columnas de destino lleva el dato. Antes eran dos tablas puente // (sap_usuario_temporada_programa / sap_usuario_ceco) más un espejo derivado que // las unía; las tres se colapsaron en esta en 86_sap_pep_ceco_una_tabla.sql, // porque Gestión de Transporte apunta ese `id` con una FOREIGN KEY y un espejo que // se borra y se reescribe en cada edición no puede ser el padre de una FK. // // Dos consecuencias que las ventanas TIENEN que respetar, y por eso los helpers de // acá abajo son el único camino para leer y escribir la tabla: // // · Las dos dimensiones comparten la tabla. Una ventana de PEP que escriba el // arreglo entero borraría los CECO, así que se escribe con `escribirAlcance`, // que conserva verbatim las filas de la otra dimensión. // · La baja es `activo = false`, nunca un DELETE: una orden de transporte vieja // tiene que poder seguir apuntando a un alcance que ya no está vigente. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const K = { USERS: "sc.usr_usuario", SGA: "sc.rrhh_estructura_sub_gerencia_area", PROY: "sc.sap_pep_proyecto", AREAU: "sc.sap_pep_area_unica", TEMP: "sc.sap_pep_temporada_programa", TEMPYEAR:"sc.sap_pep_temporada_programa_year", AREA: "sc.sap_pep_area", CECO: "sc.sap_ceco", ALCANCE: "sc.sap_pep_ceco", }; const porTexto = (s) => String(s || "").toLowerCase(); const apellidoNombre = (u) => ((u.apellido || "") + (u.apellido && u.nombre ? ", " : "") + (u.nombre || "")).trim() || u.id; // Etiqueta única de toda entidad SAP, en UNA línea y siempre igual — id + // nombre ("5-1004 Señores Papis"): el mismo texto en las grillas y en los // buscadores, así lo que se lee al agregar es idéntico a lo que queda escrito. const etiquetaIdNombre = (id, nombre) => ( {id} {nombre ? {" " + nombre} : null} ); const textoIdNombre = (id, nombre) => (nombre ? id + " " + nombre : String(id)); // Las dos dimensiones del alcance, en el orden en que se muestran: catálogo de // origen, columna de destino en sc.sap_pep_ceco y el valor de `es_pep` que // identifica a sus filas dentro de la tabla compartida. // `articulo` va con el singular porque las ventanas arman frases ("Elige una // temporada…"): sin él salía "un temporada". const DIMS = [ { key: "temp", titulo: "Temporadas / programas", singular: "temporada", articulo: "una", catalogo: K.TEMP, campo: "id_sap_pep_temporada_programa", esPep: true }, { key: "ceco", titulo: "Cecos", singular: "ceco", articulo: "un", catalogo: K.CECO, campo: "id_sap_ceco", esPep: false }, ]; // ── Leer y escribir el alcance ────────────────────────────────────────────── // Una fila está VIGENTE si `activo` no es false (una fila vieja puede no traer // la clave; el default de la columna es true). const vigente = (r) => r.activo !== false; const esDe = (dim) => (r) => !!r.es_pep === !!dim.esPep; // Todas las filas de una dimensión, vigentes y de baja: la ventana necesita las // dos, porque volver a asignar una dupla dada de baja REACTIVA su fila en vez de // crear otra (el UNIQUE parcial de la base tampoco dejaría duplicarla). const leerAlcance = (dim) => window.SCUI.read(K.ALCANCE).filter(esDe(dim)); // Escribe la dimensión conservando la otra tal cual vino. Re-lee la tabla en el // momento de escribir —y no usa la copia que la ventana leyó al montarse— // porque entre medio pudo escribir la otra ventana de alcance. const escribirAlcance = (dim, filasDim) => { const otras = window.SCUI.read(K.ALCANCE).filter((r) => !esDe(dim)(r)); window.SCUI.write(K.ALCANCE, otras.concat(filasDim)); }; // Id OPACO y que no se recicla nunca. No se usa `nextId` (máximo + 1 sobre el // arreglo): ese reusa el número de una fila borrada, y acá el id es el que // Gestión de Transporte guarda con una FK — reciclarlo haría que un id pase a // significar otra persona y otro PEP sin que nadie se entere. const nuevoIdAlcance = () => { const c = window.crypto; const hex = c && c.randomUUID ? c.randomUUID().replace(/-/g, "") : c && c.getRandomValues ? Array.from(c.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("") : String(Math.floor(Math.random() * 1e16)); return "SPC-" + hex.slice(0, 12); }; // Una fila nueva de alcance. `id_usuario` y el destino se pasan sueltos porque // las ventanas se recorren en las dos direcciones (persona → SAP y SAP → // persona) y cada una sabe cuál de los dos lados tiene a mano. const filaAlcance = (dim, idUsuario, idDestino) => ({ id: nuevoIdAlcance(), activo: true, id_usuario: idUsuario, es_pep: !!dim.esPep, [dim.campo]: idDestino, }); window.SCSap = { K, DIMS, porTexto, apellidoNombre, etiquetaIdNombre, textoIdNombre, vigente, leerAlcance, escribirAlcance, nuevoIdAlcance, filaAlcance }; })(); // ────────────────────────────────────────────────────────────────── // SCTipoColor — color de fondo del tipo de monetización. // // El color se elige en la ventana "Tipo monetización" y se guarda en // sc.monetizacion.color_fondo (hex "#RRGGBB"). Desde ahí lo leen las // ventanas que muestran el tipo o el producto que cuelga de él: propuestas // (CRM y Elemento Propuesta) y Procesar Orden Producción. Un solo lugar decide // cómo se pinta, así el mismo tipo se ve igual // en todas partes y un tipo sin color cae en el estilo neutro de siempre. // // El texto NO se elige: se calcula por luminancia, de modo que cualquier color // —claro u oscuro— quede legible sin que nadie tenga que pensarlo. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; // Paleta ofrecida en el selector. Tonos claros y bien separados entre sí: el // objetivo es distinguir tipos de un vistazo, no decorar. const PALETA = [ "#DBEAFE", "#BFDBFE", "#CFFAFE", "#D1FAE5", "#DCFCE7", "#FEF3C7", "#FFEDD5", "#FEE2E2", "#FFE4E6", "#FCE7F3", "#F3E8FF", "#E0E7FF", "#E2E8F0", "#D9F99D", "#FDE68A", "#C7D2FE", ]; const hexValido = (c) => typeof c === "string" && /^#[0-9a-fA-F]{6}$/.test(c.trim()); const rgb = (hex) => ({ r: parseInt(hex.slice(1, 3), 16), g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16), }); const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0"); // Luminancia relativa (WCAG) → decide tinta oscura o blanca sobre el color. const luminancia = (hex) => { const { r, g, b } = rgb(hex); const lin = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); }; const tinta = (hex) => (hexValido(hex) && luminancia(hex) < 0.45 ? "#ffffff" : "#111827"); // Mezcla hacia negro/blanco: el borde y el distintivo del id se derivan del // color de fondo en vez de fijarse a mano. const mezclar = (hex, hacia, p) => { const c = rgb(hex), d = rgb(hacia); return "#" + hex2(c.r + (d.r - c.r) * p) + hex2(c.g + (d.g - c.g) * p) + hex2(c.b + (d.b - c.b) * p); }; // Color de un tipo de monetización (fila de sc.monetizacion) o null. const de = (tipo) => (tipo && hexValido(tipo.color_fondo) ? tipo.color_fondo.trim() : null); const deId = (id, tipos) => de((tipos || []).find(t => t.id === id)); // Estilos listos para pegar en una caja/píldora. Sin color → `null`, y quien // llama deja el estilo neutro que ya tenía (nunca inventa un color). const estilo = (hex) => { if (!hexValido(hex)) return null; return { background: hex, color: tinta(hex), borderColor: mezclar(hex, "#000000", 0.18) }; }; // El distintivo del id va oscuro pero del MISMO tono que el fondo. Oscurecer // mezclando con negro apaga los pasteles hasta dejarlos grises, así que se // baja la luminosidad en HSL y se sube la saturación: el chip se lee como el // mismo color, no como un gris pegado al lado. const rgb2hsl = ({ r, g, b }) => { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2; if (mx === mn) return { h: 0, s: 0, l }; const d = mx - mn; const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); const h = mx === r ? ((g - b) / d + (g < b ? 6 : 0)) : mx === g ? ((b - r) / d + 2) : ((r - g) / d + 4); return { h: h / 6, s, l }; }; const hsl2hex = ({ h, s, l }) => { const f = (n) => { const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l); return 255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))); }; return "#" + hex2(f(0)) + hex2(f(8)) + hex2(f(4)); }; const estiloId = (hex) => { if (!hexValido(hex)) return null; const { h, s, l } = rgb2hsl(rgb(hex)); // Un color sin tono (gris) se queda gris oscuro; el resto conserva el suyo. const f = hsl2hex({ h, s: s < 0.05 ? s : Math.max(s, 0.5), l: 0.3 }); return { background: f, color: tinta(f) }; }; // Punto/muestra de color (listas, combobox, leyendas). const muestra = (hex, size) => ({ display: "inline-block", width: size || 12, height: size || 12, borderRadius: 3, background: hexValido(hex) ? hex : "transparent", border: "1px solid " + (hexValido(hex) ? mezclar(hex, "#000000", 0.25) : "var(--line)"), flexShrink: 0, }); // Caja/píldora coloreada — la MISMA en todas las ventanas: distintivo del id a // la izquierda y nombre al lado, pintados con el color del tipo. Sin color // válido cae en `.area-pill` tal cual (el azul neutro de siempre), de modo // que un tipo sin color nunca se ve roto. // function Caja({ id, nombre, color, title, style }) { const est = estilo(color), estId = estiloId(color); return ( {id != null && id !== "" && {id}} {nombre} ); } // Caja de un TIPO de monetización (fila de sc.monetizacion). const CajaTipo = ({ tipo, style }) => ( tipo ? : null ); // Caja de un PRODUCTO, pintada con el color del tipo del que cuelga: en las // órdenes lo que se busca de un vistazo es de qué tipo es lo que se está // vendiendo/verificando, y el producto es lo que se lee. const CajaProducto = ({ producto, tipo, style }) => ( producto ? : null ); window.SCTipoColor = { PALETA, hexValido, de, deId, estilo, estiloId, muestra, tinta, mezclar, Caja, CajaTipo, CajaProducto }; })(); // ────────────────────────────────────────────────────────────────── // SCOrden — criterios de ordenamiento compartidos por las ventanas. Toda lista // de tipos de monetización que se muestre en un combobox va en orden // ALFABÉTICO por nombre (regla transversal de la plataforma): un solo // comparador acá evita que cada ventana invente el suyo. No muta el arreglo. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const porNombre = (a, b) => String(a || "").localeCompare(String(b || ""), "es", { sensitivity: "base" }); window.SCOrden = { porNombre, // Tipos de monetización (sc.monetizacion) alfabéticos por nombre. tiposMonetizacion: (list) => (list || []).slice() .sort((x, y) => porNombre(x.nombre_monetizacion || x.nombre || x.id, y.nombre_monetizacion || y.nombre || y.id)), }; })(); // ────────────────────────────────────────────────────────────────── // SCFact — a quién se factura una orden. La orden ya NO guarda el // cliente/proveedor de facturación: vive en su elemento (elemento_venta / // elemento_compra). Este helper deriva ese dato desde el elemento enlazado, // con fallback al cliente/proveedor comercial del elemento. Único lugar donde // se resuelve la facturación de una orden → sin repetir el lookup por archivo. // ────────────────────────────────────────────────────────────────── (function () { "use strict"; const read = (k) => { try { const p = JSON.parse(localStorage.getItem(k) || "[]"); return Array.isArray(p) ? p : []; } catch (e) { return []; } }; const mapById = (arr) => Object.fromEntries(arr.map((e) => [e.id, e])); window.SCFact = { // Mapas reutilizables (constrúyelos una vez y pásalos en bucles calientes). elVentaById: () => mapById(read("sc.elemento_venta")), elCompraById: () => mapById(read("sc.elemento_compra")), // Cliente de facturación de una orden_de_venta (vía su elemento_venta). cliFactDeOrden(orden, elById) { if (!orden) return ""; const m = elById || this.elVentaById(); const el = m[orden.id_elemento_venta]; return (el && (el.id_empresa_facturacion || el.id_empresa_cliente)) || ""; }, // Proveedor de facturación de una orden_de_compra (vía su elemento_compra). provFactDeOrden(orden, elById) { if (!orden) return ""; const m = elById || this.elCompraById(); const el = m[orden.id_elemento_compra]; return (el && (el.id_empresa_facturacion || el.id_empresa_proveedor)) || ""; }, }; })(); (function () { "use strict"; // Icon seguro ante orden de carga (window.Icon lo publica app.jsx). const Icon = (props) => { const I = window.Icon; return I ? React.createElement(I, props) : null; }; // ── LocalStorage (arreglos de filas) ───────────────────────────── function read(key) { try { const r = localStorage.getItem(key); if (!r) return []; const p = JSON.parse(r); return Array.isArray(p) ? p : []; } catch (e) { return []; } } function write(key, arr) { try { localStorage.setItem(key, JSON.stringify(arr)); } catch (e) {} } function nextId(arr, prefix, field) { field = field || "id"; const max = arr.reduce((m, r) => Math.max(m, parseInt(String(r[field] || "").replace(/\D/g, ""), 10) || 0), 0); return prefix + (max + 1); } const fmtCLP = (n) => (n == null || isNaN(n)) ? "—" : "$" + Number(n).toLocaleString("es-CL"); // ── Estilos / átomos visuales ──────────────────────────────────── const IPT = { height: 28, width: "100%", padding: "0 8px", border: "1px solid var(--line)", borderRadius: 4, fontSize: 12.5, fontFamily: "inherit", background: "var(--bg)", color: "var(--ink)" }; const LBL = { fontSize: 11, fontWeight: 700, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: 0.06 }; const Pill = ({ on, onT, offT }) => ( {on ? (onT || "Activo") : (offT || "Inactivo")} ); const Chip = ({ children, tone }) => ( {children} ); // ── Cáscara estándar de un mantenedor (cabecera + hero + cuerpo) ── function DetailShell({ m, group, app, meta, sub, children, onBack }) { return (
{m.title}›{group.title}
{meta}

{app.title}

{sub}
{children}
); } // ── Matriz genérica de asignación (junction: izquierda × derecha) ── function JunctionMatrix({ leftItems, leftLabel, rightItems, rightLabel, isLinked, toggle, groupRight, rightSearch }) { const [selLeft, setSelLeft] = React.useState(() => leftItems[0] ? leftItems[0].id : null); const [q, setQ] = React.useState(""); const [qr, setQr] = React.useState(""); React.useEffect(() => { if (!leftItems.some(x => x.id === selLeft)) setSelLeft(leftItems[0] ? leftItems[0].id : null); }, [leftItems]); const needle = q.trim().toLowerCase(); const visLeft = leftItems.filter(x => !needle || (x.title + " " + (x.subtitle || "")).toLowerCase().includes(needle)); const cur = leftItems.find(x => x.id === selLeft) || null; const rNeedle = qr.trim().toLowerCase(); const rItems = (rightSearch && rNeedle) ? rightItems.filter(r => (r.title || "").toLowerCase().includes(rNeedle)) : rightItems; const groups = React.useMemo(() => { if (!groupRight) return [{ title: null, items: rItems }]; const by = {}; const order = []; rItems.forEach(r => { const g = r.group || "—"; if (!by[g]) { by[g] = []; order.push(g); } by[g].push(r); }); return order.map(g => ({ title: g, items: by[g] })); }, [rItems, groupRight]); const linkedCount = cur ? rightItems.filter(r => isLinked(cur.id, r.id)).length : 0; return (
{leftLabel}
setQ(e.target.value)}/>
{visLeft.length === 0 &&
Sin resultados.
} {visLeft.map(x => { const active = x.id === selLeft; const n = rightItems.filter(r => isLinked(x.id, r.id)).length; return (
setSelLeft(x.id)} style={{ padding: "8px 10px", cursor: "pointer", borderBottom: "1px solid var(--line)", background: active ? "rgba(30,77,191,0.08)" : "transparent", opacity: x.activo === false ? 0.55 : 1 }}>
{x.title} {n}
{x.subtitle &&
{x.subtitle}
}
); })}
{rightLabel}
{cur ? {cur.title} · {linkedCount} asignado{linkedCount === 1 ? "" : "s"} : Selecciona un elemento de la izquierda} {rightSearch && cur && setQr(e.target.value)}/>}
{!cur &&
Nada seleccionado.
} {cur && rightItems.length === 0 &&
No hay {rightLabel.toLowerCase()} activos para asignar.
} {cur && groups.map((g, gi) => (
{g.title &&
{g.title}
}
{g.items.map(r => { const on = isLinked(cur.id, r.id); return ( ); })}
))}
); } // ── Filtro de estado: la barra de chips de la plataforma ────────── // Activo/inactivo NO se filtra con un combobox: se filtra con chips, igual que // el tipo de canal en «Canales de distribución» (.senal-tipo-bar). Se ve el // recuento de cada estado sin abrir nada y se cambia con un clic. // // `singular` sólo cambia la etiqueta ("activas" en un catálogo femenino). function FiltroEstado({ valor, onChange, filas, etiquetas }) { const rows = filas || []; const nAct = rows.filter(r => r.activo !== false).length; const L = etiquetas || {}; const opciones = [ ["todos", L.todos || "Todos", rows.length], ["activos", L.activos || "Activos", nAct], ["inactivos", L.inactivos || "Inactivos", rows.length - nAct], ]; return (
{opciones.map(([v, txt, n]) => ( ))}
); } // ── Asignador de tres columnas: los átomos que comparten sus ventanas ── // «Roles -> usuarios» y «Usuarios -> roles» son DOS ventanas de verdad, cada // una con su ítem de menú y su propio archivo — no una sola con un prop que // esconde la mitad. Lo que comparten es esto: la caja de una columna, su // cabecera con el conteo, la fila de vacío y cómo se arma el nombre de una // persona. Vive acá para que no haya dos copias que se separen con el tiempo. // apellido + apellido materno + nombres, colapsando los vacíos: hay usuarios // sin segundo apellido y con un join a secas quedaba un espacio doble. const nombrePersona = (u) => !u ? "" : [u.apellido, u.segundo_apellido, u.nombre].map(v => String(v || "").trim()).filter(Boolean).join(" "); // Comparador de textos en español (acentos y mayúsculas no alteran el orden). const cmpEs = (a, b) => String(a || "").localeCompare(String(b || ""), "es", { sensitivity: "base" }); // ── nombreRol — cómo se ESCRIBE un rol en pantalla ───────────────── // Un rol puede declarar a qué sistema pertenece (sc.usr_rol.id_usr_sistema, // migración 84). Cuando lo declara se lee «sistema/rol» —«Sistema // Comercial/Coordinador»—; cuando no, se lee «(all systems)/rol». // // El rótulo de los transversales lo fija docs/guidelines.md y se escribe TAL // CUAL, en inglés: un rol sin sistema no es un rol sin dato, es uno que vale // para todos, y antes se leía igual que cualquier otro —el nombre solo—, así // que en una lista mezclada no había forma de distinguirlos. // // La regla vive ACÁ y no en cada ventana: son siete las que dibujan un rol // («Roles», «Roles -> usuarios» y su espejo, «Roles -> Menu» y el suyo, // «Rol -> Modulos» y el suyo) y repetir el armado en cada una garantizaba que // alguna se quedara atrás. // // El catálogo de sistemas se lee de localStorage en cada llamada: es una tabla // de siete filas, y cachearla haría que un sistema recién renombrado siguiera // saliendo con el nombre viejo hasta recargar. function nombreRol(rol) { if (!rol) return ""; const propio = rol.nombre || rol.id || ""; if (!rol.id_usr_sistema) return "(all systems)/" + propio; const sis = read("sc.usr_sistema").find(s => s.id === rol.id_usr_sistema); // Sin el sistema en el catálogo se muestra su id: decir sólo el nombre del // rol escondería que apunta a algo que no está. return (sis ? (sis.nombre || sis.id) : rol.id_usr_sistema) + "/" + propio; } // ── usuariosEjecutivos — quién puede salir en un combo de ejecutivo ──── // Lo fija docs/guidelines.md: los combos que eligen // sc.oportunidad.id_usuario · sc.propuesta_monetizacion.id_usuario · // sc.equipo_comercial_miembro.id_usuario // sólo ofrecen usuarios con un rol asociado al módulo cuyo NOMBRE es // `combobox_seleccion_ejecutivo_comercial` (hoy id `mod-1`, pero el nombre es // lo que nombra la guía, así que se busca por nombre: el id es incidental). // // Cadena: usr_modulo → usr_rol_modulo → usr_usuario_rol → usr_usuario. // Hoy son 26 de 974 usuarios activos. // // La regla vive ACÁ y no en cada ventana, por lo mismo que nombreRol: son // cuatro los combos que la necesitan, repartidos en tres archivos, y repetir // el armado garantizaba que alguno se quedara atrás. // // `actual` conserva al usuario YA GUARDADO en la fila aunque hoy no sea // ejecutivo: sacarlo de las opciones dejaría el select en blanco y el // siguiente cambio de cualquier otro campo lo borraría sin que nadie lo pida. function usuariosEjecutivos(usuarios, actual) { const mod = read("sc.usr_modulo") .find(m => String(m.nombre || "").trim() === "combobox_seleccion_ejecutivo_comercial"); if (!mod) return []; const roles = new Set(read("sc.usr_rol_modulo") .filter(x => x.id_modulo === mod.id).map(x => x.id_rol)); const ids = new Set(read("sc.usr_usuario_rol") .filter(x => roles.has(x.id_rol)).map(x => x.id_usuario)); return (usuarios || []).filter(u => ids.has(u.id) || (actual && u.id === actual)); } function CabeceraColumna({ titulo, visibles, total, children }) { return (
{titulo}
{visibles === total ? total : `${visibles} de ${total}`}
{children}
); } // `crece` es el reparto del ancho sobrante entre columnas (flex-grow). Con la // base en 0, ES lo que fija el ancho final: 1 en todas = todas iguales, que es // el reparto por defecto y el que ven las ventanas que no lo pasan. // Columna de una pantalla de varias columnas. El ANCHO SE ARRASTRA desde su // borde derecho: los nombres largos —«Contabilizacion Ingresos y Pagos», un // apellido compuesto— no entran en un ancho fijo, y cuál es el ancho bueno // depende de los datos de cada quien, no de lo que se decida acá. // // Mientras nadie la toque, la columna se reparte el espacio con sus hermanas // (flex). Al arrastrarla pasa a ancho FIJO: quien la movió eligió ese ancho y no // se lo vuelve a repartir el navegador. // // Con `id`, el ancho elegido se guarda y vuelve en la próxima visita. Sin `id` // se arrastra igual, pero se pierde al recargar. function ColumnaLista({ ancho, crece = 1, id, children }) { const CLAVE = id ? "sc.ui.ancho." + id : null; const [w, setW] = React.useState(() => { if (!CLAVE) return null; try { const v = parseInt(localStorage.getItem(CLAVE) || "", 10); return v > 0 ? v : null; } catch (e) { return null; } }); const ref = React.useRef(null); // Antes de mover NADA, se congelan las hermanas. Sin esto, fijar el ancho de // una columna deja que flex reparta el sobrante entre las demás: al agrandar // la tercera se encogía la primera, que nadie había tocado. Cada columna se // congela sola —se avisa por evento y ella mide su propio ancho—, porque son // componentes hermanos sin estado en común. const congelarHermanas = () => { if (!ref.current) return; try { window.dispatchEvent(new CustomEvent("sc:col:congelar", { detail: { contenedor: ref.current.parentNode, salvo: ref.current }, })); } catch (e) {} }; React.useEffect(() => { const alCongelar = (e) => { const d = e && e.detail; if (!d || !ref.current) return; if (d.contenedor !== ref.current.parentNode) return; // otra pantalla if (d.salvo === ref.current) return; // la que se arrastra setW(prev => (prev == null ? ref.current.offsetWidth : prev)); }; window.addEventListener("sc:col:congelar", alCongelar); return () => window.removeEventListener("sc:col:congelar", alCongelar); }, []); const arrastrar = (ev) => { ev.preventDefault(); // sin esto el navegador selecciona texto congelarHermanas(); const x0 = ev.clientX; const w0 = ref.current ? ref.current.offsetWidth : (ancho || 200); const mover = (e) => { // Nunca por debajo del mínimo declarado: una columna de 20px no se lee y // recuperarla exige adivinar dónde quedó su borde. const nuevo = Math.max(ancho || 120, Math.round(w0 + (e.clientX - x0))); setW(nuevo); }; const soltar = () => { window.removeEventListener("mousemove", mover); window.removeEventListener("mouseup", soltar); document.body.style.userSelect = ""; document.body.style.cursor = ""; if (CLAVE && ref.current) { try { localStorage.setItem(CLAVE, String(ref.current.offsetWidth)); } catch (e) {} } }; document.body.style.userSelect = "none"; // el cursor manda en toda la página document.body.style.cursor = "col-resize"; window.addEventListener("mousemove", mover); window.addEventListener("mouseup", soltar); }; // Doble clic en el borde: vuelve al reparto automático. const reiniciar = () => { setW(null); if (CLAVE) { try { localStorage.removeItem(CLAVE); } catch (e) {} } }; return (
{children} {/* El asa vive DENTRO de la columna, pegada a su borde derecho, y se pinta sola al pasar por encima: una barra siempre visible entre cada par de columnas ensucia una pantalla que ya tiene cuatro. */}
); } // ── El árbol de menú aplanado, EN ORDEN, para las ventanas de permisos ── // «Roles -> Menu» y «Menu -> Roles» tratan títulos e ítems como filas de una // misma lista: un título y luego los suyos, sistema por sistema. Devuelve una // fila por nodo con su etiqueta ya armada (sistema › título [› ítem]). // // El orden es el del menú de verdad: sistema por id, título por id y el ítem por // su columna `orden` (35_rol_menu.sql). Hasta esa migración el orden ERA el id; // el `|| 0` de abajo mantiene el criterio viejo para una fila que todavía no // tenga `orden` —recién creada en el navegador, por ejemplo— y el id desempata. function menuPlano({ sistemas, titulos, items }) { const nombreSistema = {}; (sistemas || []).forEach(s => { nombreSistema[s.id] = s.nombre; }); const itemsPorTitulo = {}; (items || []).forEach(it => { const k = it.id_usr_sistema_menu_titulo; if (!k) return; (itemsPorTitulo[k] = itemsPorTitulo[k] || []).push(it); }); Object.keys(itemsPorTitulo).forEach(k => itemsPorTitulo[k].sort((a, b) => ((a.orden || 0) - (b.orden || 0)) || String(a.id).localeCompare(String(b.id)))); const filas = []; (titulos || []).slice() .sort((a, b) => String(a.id_sistema || "").localeCompare(String(b.id_sistema || "")) || String(a.id).localeCompare(String(b.id))) .forEach(t => { const sis = nombreSistema[t.id_sistema] || t.id_sistema || "—"; // La etiqueta NO repite el sistema: la lista ya se filtra por sistema con // su combo, así que el prefijo era el mismo en todas las filas y lo único // que hacía era empujar el nombre del ítem —lo que de verdad se busca— // hasta cortarlo con puntos suspensivos. El sistema sigue en la fila // (`sistema`, `idSistema`) y en `etiquetaCompleta`, que es la que va al // tooltip: ahí el contexto completo no cuesta ancho. filas.push({ clave: "T:" + t.id, tipo: "titulo", id: t.id, idSistema: t.id_sistema, sistema: sis, titulo: t.nombre, item: null, etiqueta: (t.nombre || t.id), etiquetaCompleta: sis + " › " + (t.nombre || t.id) }); (itemsPorTitulo[t.id] || []).forEach(it => { filas.push({ clave: "I:" + it.id, tipo: "item", id: it.id, idSistema: t.id_sistema, sistema: sis, titulo: t.nombre, item: it.nombre, etiqueta: (t.nombre || t.id) + " › " + (it.nombre || it.id), etiquetaCompleta: sis + " › " + (t.nombre || t.id) + " › " + (it.nombre || it.id) }); }); }); return filas; } const FilaVacia = ({ colSpan, children }) => ( {children} ); // ── URL guardada -> URL utilizable desde cualquier aplicación ── // Las páginas embebidas (inventario-mega.html, editor-*.html) viven en la RAÍZ // del sitio, pero cada sistema se sirve desde su propio directorio // (/Sistema_Comercial/…). Una ruta relativa resolvería dentro del directorio y // daría 404: acá se ancla a la raíz. http(s), // y /… se devuelven intactas. function urlRaiz(u) { const s = String(u == null ? "" : u).trim(); if (!s) return ""; if (/^([a-z]+:)?\/\//i.test(s) || s.startsWith("/") || s.startsWith("data:") || s.startsWith("blob:")) return s; return "/" + s.replace(/^\.\//, ""); } window.SCUI = { Icon, read, write, nextId, fmtCLP, IPT, LBL, Pill, Chip, DetailShell, JunctionMatrix, urlRaiz, FiltroEstado, nombrePersona, nombreRol, usuariosEjecutivos, cmpEs, CabeceraColumna, ColumnaLista, FilaVacia, menuPlano }; })(); // ── ¿Es un canal de parrilla? ──────────────────────────────────────────────── // Se pregunta por el MEDIO, que es la fila del catálogo, y no por el texto suelto // de `tipo_canal`. Vive acá y no en la ventana de canales porque lo usan cuatro // ventanas de Programación y la ventana de Comercial: una sola definición, que es // lo que evita que renombrar el valor obligue a acertarle a varias copias. (function () { "use strict"; const MEDIO_TV_LINEAL = "TV Lineal"; window.SCCanal = { MEDIO_TV_LINEAL, esLineal: (c) => !!c && String(c.id_medio || "").trim().toLowerCase() === MEDIO_TV_LINEAL.toLowerCase(), }; })();