Actualizar frontend/src/lib/api.js

This commit is contained in:
2026-09-04 16:07:16 -05:00
parent 286d4aff1e
commit 0d09be8e4e

View File

@@ -3,7 +3,6 @@
// 2) Si no, se decide por el host desde el que se abrió la página: // 2) Si no, se decide por el host desde el que se abrió la página:
// localhost -> backend local http://localhost:8001 // localhost -> backend local http://localhost:8001
// cualquier otro -> API publicada del servidor // cualquier otro -> API publicada del servidor
// Así funciona en desarrollo y en el servidor sin necesitar ningún .env.
const API_LOCAL = "http://localhost:8001"; const API_LOCAL = "http://localhost:8001";
const API_SERVIDOR = "https://api-leads.escueladerefrigeracion.lat"; const API_SERVIDOR = "https://api-leads.escueladerefrigeracion.lat";
@@ -20,14 +19,34 @@ const BASE_URL = (
).replace(/\/+$/, ""); ).replace(/\/+$/, "");
// ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ── // ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ──
// Guarda la respuesta de cada GET (por path+params). Al volver a pedir:
// - si hay dato vigente (< 15 min) -> lo devuelve al instante, SIN fetch.
// - si venció -> devuelve el viejo al instante Y refresca en 2º plano.
// Así, al cambiar de apartado y volver, NO se recarga; y a los 15 min se
// actualiza solo sin mostrar loader. Los POST invalidan el cache.
const TTL = 15 * 60 * 1000; // 15 minutos const TTL = 15 * 60 * 1000; // 15 minutos
const _cache = new Map(); // key -> { ts, data } const _cache = new Map(); // key -> { ts, data }
const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado) const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado)
const _gen = new Map(); // key -> nº de generación (para invalidar en vuelo)
function _genDe(k) { return _gen.get(k) || 0; }
// ── Persistencia en sessionStorage para que el cache SOBREVIVA al F5 ──
const _SS_KEY = "leads_api_cache_v1";
function _persistir() {
try {
const obj = {};
for (const [k, v] of _cache.entries()) obj[k] = v;
sessionStorage.setItem(_SS_KEY, JSON.stringify(obj));
} catch (e) { /* cuota llena o no disponible: ignorar */ }
}
(function _rehidratar() {
try {
const raw = sessionStorage.getItem(_SS_KEY);
if (!raw) return;
const obj = JSON.parse(raw);
for (const k in obj) _cache.set(k, obj[k]);
} catch (e) { /* ignorar */ }
})();
function _guardar(key, data) {
_cache.set(key, { ts: Date.now(), data });
_persistir();
}
function _key(path, params) { function _key(path, params) {
return path + "?" + new URLSearchParams(params).toString(); return path + "?" + new URLSearchParams(params).toString();
@@ -40,42 +59,39 @@ async function _fetch(path, params) {
return r.json(); return r.json();
} }
// GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache.
function get(path, params = {}) { function get(path, params = {}) {
const key = _key(path, params); const key = _key(path, params);
const hit = _cache.get(key); const hit = _cache.get(key);
const now = Date.now(); const now = Date.now();
if (hit) { if (hit) {
// Hay algo cacheado -> devolverlo YA.
if (now - hit.ts >= TTL && !_enVuelo.has(key)) { if (now - hit.ts >= TTL && !_enVuelo.has(key)) {
// Venció: refrescar en 2º plano (no bloquea, no muestra loader). const g = _genDe(key);
const p = _fetch(path, params) const p = _fetch(path, params)
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) .then((data) => { if (_genDe(key) === g) _guardar(key, data); return data; })
.finally(() => _enVuelo.delete(key)); .finally(() => { if (_enVuelo.get(key) === p) _enVuelo.delete(key); });
_enVuelo.set(key, p); _enVuelo.set(key, p);
} }
return Promise.resolve(hit.data); return Promise.resolve(hit.data);
} }
// No hay cache: si ya hay una petición en vuelo para esta key, reusarla.
if (_enVuelo.has(key)) return _enVuelo.get(key); if (_enVuelo.has(key)) return _enVuelo.get(key);
const g = _genDe(key);
const p = _fetch(path, params) const p = _fetch(path, params)
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) .then((data) => { if (_genDe(key) === g) _guardar(key, data); return data; })
.finally(() => _enVuelo.delete(key)); .finally(() => { if (_enVuelo.get(key) === p) _enVuelo.delete(key); });
_enVuelo.set(key, p); _enVuelo.set(key, p);
return p; return p;
} }
// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados.
function invalidar(...prefijos) { function invalidar(...prefijos) {
for (const k of _cache.keys()) { const afecta = (k) => prefijos.some((p) => k.startsWith(p));
if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k); for (const k of _cache.keys()) { if (afecta(k)) _cache.delete(k); }
} for (const k of _enVuelo.keys()) { if (afecta(k)) { _gen.set(k, _genDe(k) + 1); _enVuelo.delete(k); } }
_persistir();
} }
// POST helper: hace el POST y luego invalida los caches afectados.
async function post(path, body, invalidarPrefijos = []) { async function post(path, body, invalidarPrefijos = []) {
const r = await fetch(`${BASE_URL}${path}`, { const r = await fetch(`${BASE_URL}${path}`, {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
@@ -87,6 +103,8 @@ async function post(path, body, invalidarPrefijos = []) {
return data; return data;
} }
const _TODOS = ["/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general", "/api/programa", "/api/alertas"];
export const api = { export const api = {
leadsFiltros: () => get("/api/leads/filtros"), leadsFiltros: () => get("/api/leads/filtros"),
leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") => leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") =>
@@ -95,29 +113,30 @@ export const api = {
get("/api/otros-general", { ano, mes, dia }), get("/api/otros-general", { ano, mes, dia }),
vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/vendedores", { ano, mes, dia }), get("/api/vendedores", { ano, mes, dia }),
tiempoRespuestaDetalle: (dia, modo = "asignacion", vendedor = "TODOS", ano = "TODOS", mes = "TODOS") =>
get("/api/tiempo-respuesta/detalle", { dia, modo, vendedor, ano, mes }),
roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/roas", { ano, mes, dia }), get("/api/roas", { ano, mes, dia }),
programaOpciones: (ano = "TODOS", mes = "TODOS") =>
get("/api/programa/opciones", { ano, mes }),
programa: (ano = "TODOS", mes = "TODOS", programa = "TODOS") =>
get("/api/programa", { ano, mes, programa }),
alertas: () => _fetch("/api/alertas", {}),
conjuntosSinSede: () => get("/api/conjuntos-sin-sede"), conjuntosSinSede: () => get("/api/conjuntos-sin-sede"),
guardarConjuntoSede: (cambios) => guardarConjuntoSede: (cambios) =>
post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", "/api/roas"]), post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", ..._TODOS]),
borrarConjuntoSede: (conjunto) => borrarConjuntoSede: (conjunto) =>
post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", "/api/roas"]), post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", ..._TODOS]),
ultimaActualizacion: () => get("/api/ultima-actualizacion"), ultimaActualizacion: () => get("/api/ultima-actualizacion"),
// Actualiza TODO manualmente (como si pasaran los 15 min), pero EN SEGUNDO
// PLANO: NO se toca el cache del cliente mientras el backend trabaja, así el
// usuario sigue viendo los datos viejos sin pantallas de carga durante los
// ~29s. Recien cuando el backend responde OK se limpia el cache del cliente,
// para que a partir de ahi todo muestre los datos nuevos de inmediato.
refrescarTodo: async () => { refrescarTodo: async () => {
const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" }); const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" });
if (!r.ok) throw new Error("Error al actualizar"); if (!r.ok) throw new Error("Error al actualizar");
await r.json(); // espera a que el backend TERMINE de rehacer todo await r.json();
_cache.clear(); // solo AHORA se invalida el cache del cliente _cache.clear();
// Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para _persistir();
// que el sidebar muestre la hora del refresco manual recien hecho.
const hr = await _fetch("/api/ultima-actualizacion", {}); const hr = await _fetch("/api/ultima-actualizacion", {});
_cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr }); _guardar(_key("/api/ultima-actualizacion", {}), hr);
return hr; // { hora: "..." } return hr;
}, },
conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"), conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"),
usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }), usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }),
@@ -126,23 +145,22 @@ export const api = {
programasOcultos: () => get("/api/programas-ocultos"), programasOcultos: () => get("/api/programas-ocultos"),
encenderProgramas: (num_indices) => encenderProgramas: (num_indices) =>
post("/api/programas-ocultos/encender", { num_indices }, post("/api/programas-ocultos/encender", { num_indices },
["/api/programas-ocultos", "/api/programas-disponibles", "/api/leads"]), ["/api/programas-ocultos", "/api/programas-disponibles", ..._TODOS]),
guardarLeyenda: (cambios) => guardarLeyenda: (cambios) =>
post("/api/leyenda-anuncios/guardar", { cambios }, ["/api/leyenda-anuncios", "/api/roas"]), post("/api/leyenda-anuncios/guardar", { cambios },
["/api/leyenda-anuncios", ..._TODOS]),
guardarPauta: (num_indice, pauta, conjunto, contar) => guardarPauta: (num_indice, pauta, conjunto, contar) =>
post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar }, post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar },
["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]), ["/api/conjuntos-sin-pauta", ..._TODOS]),
alertas: () => get("/api/leads/alertas"), alertasClasif: () => get("/api/leads/alertas"),
// Leyenda (campanias de Supabase). Al agregar/borrar, invalida TODOS los
// apartados que dependen de las leyendas para que el cambio se vea al instante.
campanias: () => get("/api/campanias"), campanias: () => get("/api/campanias"),
agregarCampania: (datos) => agregarCampania: (datos) =>
post("/api/campanias/agregar", datos, post("/api/campanias/agregar", datos,
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general", "/api/programa"]),
editarCampania: (id, datos) => editarCampania: (id, datos) =>
post("/api/campanias/editar", { id, ...datos }, post("/api/campanias/editar", { id, ...datos },
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general", "/api/programa"]),
borrarCampania: (id) => borrarCampania: (id) =>
post("/api/campanias/borrar", { id }, post("/api/campanias/borrar", { id },
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general", "/api/programa"]),
}; };