diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 3c65006..1867d69 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -3,7 +3,6 @@ // 2) Si no, se decide por el host desde el que se abrió la página: // localhost -> backend local http://localhost:8001 // 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_SERVIDOR = "https://api-leads.escueladerefrigeracion.lat"; @@ -20,14 +19,34 @@ const BASE_URL = ( ).replace(/\/+$/, ""); // ── 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 _cache = new Map(); // key -> { ts, data } 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) { return path + "?" + new URLSearchParams(params).toString(); @@ -40,42 +59,39 @@ async function _fetch(path, params) { return r.json(); } -// GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache. function get(path, params = {}) { const key = _key(path, params); const hit = _cache.get(key); const now = Date.now(); if (hit) { - // Hay algo cacheado -> devolverlo YA. 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) - .then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) - .finally(() => _enVuelo.delete(key)); + .then((data) => { if (_genDe(key) === g) _guardar(key, data); return data; }) + .finally(() => { if (_enVuelo.get(key) === p) _enVuelo.delete(key); }); _enVuelo.set(key, p); } 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); + const g = _genDe(key); const p = _fetch(path, params) - .then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) - .finally(() => _enVuelo.delete(key)); + .then((data) => { if (_genDe(key) === g) _guardar(key, data); return data; }) + .finally(() => { if (_enVuelo.get(key) === p) _enVuelo.delete(key); }); _enVuelo.set(key, p); return p; } -// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados. function invalidar(...prefijos) { - for (const k of _cache.keys()) { - if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k); - } + const afecta = (k) => prefijos.some((p) => k.startsWith(p)); + 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 = []) { const r = await fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -87,6 +103,8 @@ async function post(path, body, invalidarPrefijos = []) { return data; } +const _TODOS = ["/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general", "/api/programa", "/api/alertas"]; + export const api = { leadsFiltros: () => get("/api/leads/filtros"), 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 }), vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => 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") => 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"), 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) => - 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"), - // 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 () => { const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" }); if (!r.ok) throw new Error("Error al actualizar"); - await r.json(); // espera a que el backend TERMINE de rehacer todo - _cache.clear(); // solo AHORA se invalida el cache del cliente - // Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para - // que el sidebar muestre la hora del refresco manual recien hecho. + await r.json(); + _cache.clear(); + _persistir(); const hr = await _fetch("/api/ultima-actualizacion", {}); - _cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr }); - return hr; // { hora: "..." } + _guardar(_key("/api/ultima-actualizacion", {}), hr); + return hr; }, conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"), usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }), @@ -126,23 +145,22 @@ export const api = { programasOcultos: () => get("/api/programas-ocultos"), encenderProgramas: (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) => - 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) => post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar }, - ["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]), - alertas: () => 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. + ["/api/conjuntos-sin-pauta", ..._TODOS]), + alertasClasif: () => get("/api/leads/alertas"), campanias: () => get("/api/campanias"), agregarCampania: (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) => 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) => 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"]), +}; \ No newline at end of file