Agregar selector de equipos Teams y filtro Graph por group_id.
Incluye API /api/groups, cache por equipo en el servidor y correccion del volumen config en docker-compose. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7fb305ab2b
commit
a5a2559c6d
3 changed files with 308 additions and 56 deletions
|
|
@ -56,6 +56,7 @@ DEFAULT_ACTA_CONFIG: dict = {
|
|||
}
|
||||
|
||||
_CLIENT_GONE_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)
|
||||
_ALL_TEAMS_KEY = "__all__"
|
||||
|
||||
|
||||
def load_acta_config() -> dict:
|
||||
|
|
@ -154,6 +155,30 @@ def schedule_open_browser(port: int) -> None:
|
|||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
|
||||
def _team_cache_key(group_id: str | None) -> str:
|
||||
gid = (group_id or "").strip()
|
||||
return gid if gid else _ALL_TEAMS_KEY
|
||||
|
||||
|
||||
def fetch_teams_list() -> list[dict[str, str]]:
|
||||
"""Lista equipos de Teams con Planner (Graph). Vacio si la fuente no lo soporta."""
|
||||
service = create_data_service()
|
||||
list_fn = getattr(service, "get_groups_with_planner", None)
|
||||
if not callable(list_fn):
|
||||
return []
|
||||
groups = list_fn()
|
||||
teams = [
|
||||
{
|
||||
"id": str(g.get("id", "")).strip(),
|
||||
"name": str(g.get("displayName") or g.get("id") or "").strip(),
|
||||
}
|
||||
for g in groups
|
||||
if g.get("id")
|
||||
]
|
||||
teams.sort(key=lambda t: t["name"].casefold())
|
||||
return teams
|
||||
|
||||
|
||||
class DashboardCache:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -167,9 +192,11 @@ class DashboardCache:
|
|||
self._on_updated = on_updated
|
||||
self._data_lock = threading.Lock()
|
||||
self._refresh_lock = threading.Lock()
|
||||
self._refreshing = False
|
||||
self._cached_at = 0.0
|
||||
self._cached_tsv = ""
|
||||
self._refreshing_keys: set[str] = set()
|
||||
self._tsv_entries: dict[str, tuple[str, float]] = {}
|
||||
self._teams: list[dict[str, str]] = []
|
||||
self._teams_cached_at = 0.0
|
||||
self._teams_ttl = max(ttl_seconds * 6, 60)
|
||||
self._last_error = ""
|
||||
self._last_background_at = 0.0
|
||||
|
||||
|
|
@ -180,88 +207,127 @@ class DashboardCache:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _fetch_fresh(self) -> str:
|
||||
def _fetch_fresh_tsv(self, group_id: str | None = None) -> str:
|
||||
service = create_data_service()
|
||||
projects = service.get_all_projects()
|
||||
gid = (group_id or "").strip()
|
||||
group_ids = [gid] if gid else None
|
||||
projects = service.get_all_projects(group_ids=group_ids)
|
||||
return projects_to_tsv(projects)
|
||||
|
||||
def _cache_age(self) -> float:
|
||||
if not self._cached_tsv:
|
||||
def _entry_age(self, key: str) -> float:
|
||||
entry = self._tsv_entries.get(key)
|
||||
if not entry:
|
||||
return float("inf")
|
||||
return time.time() - self._cached_at
|
||||
return time.time() - entry[1]
|
||||
|
||||
def _is_stale(self) -> bool:
|
||||
if not self._cached_tsv:
|
||||
return True
|
||||
return self._cache_age() >= self.ttl_seconds
|
||||
def _is_stale(self, key: str) -> bool:
|
||||
return key not in self._tsv_entries or self._entry_age(key) >= self.ttl_seconds
|
||||
|
||||
def _should_background_refresh(self) -> bool:
|
||||
if self._refreshing:
|
||||
def _get_cached_tsv(self, key: str) -> str:
|
||||
entry = self._tsv_entries.get(key)
|
||||
return entry[0] if entry else ""
|
||||
|
||||
def _should_background_refresh(self, key: str) -> bool:
|
||||
if key in self._refreshing_keys:
|
||||
return False
|
||||
since_bg = time.time() - self._last_background_at
|
||||
if since_bg < self.min_background_seconds:
|
||||
return False
|
||||
return self._is_stale()
|
||||
return self._is_stale(key)
|
||||
|
||||
def _start_background_refresh(self) -> None:
|
||||
def _start_background_refresh(self, group_id: str | None = None) -> None:
|
||||
key = _team_cache_key(group_id)
|
||||
with self._refresh_lock:
|
||||
if self._refreshing:
|
||||
if key in self._refreshing_keys:
|
||||
return
|
||||
self._refreshing = True
|
||||
self._refreshing_keys.add(key)
|
||||
self._last_background_at = time.time()
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
self._refresh()
|
||||
self._refresh(group_id)
|
||||
finally:
|
||||
with self._refresh_lock:
|
||||
self._refreshing = False
|
||||
self._refreshing_keys.discard(key)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _refresh(self) -> tuple[str, bool]:
|
||||
def _refresh_stale_entries(self) -> None:
|
||||
with self._data_lock:
|
||||
keys = list(self._tsv_entries.keys())
|
||||
for key in keys:
|
||||
if self._is_stale(key):
|
||||
gid = None if key == _ALL_TEAMS_KEY else key
|
||||
self._start_background_refresh(gid)
|
||||
|
||||
def _refresh(self, group_id: str | None = None) -> tuple[str, bool]:
|
||||
key = _team_cache_key(group_id)
|
||||
try:
|
||||
fresh = self._fetch_fresh()
|
||||
fresh = self._fetch_fresh_tsv(group_id)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
with self._data_lock:
|
||||
if self._cached_tsv:
|
||||
return self._cached_tsv, True
|
||||
cached = self._get_cached_tsv(key)
|
||||
if cached:
|
||||
return cached, True
|
||||
raise
|
||||
with self._data_lock:
|
||||
self._cached_tsv = fresh
|
||||
self._cached_at = time.time()
|
||||
self._tsv_entries[key] = (fresh, time.time())
|
||||
self._last_error = ""
|
||||
self._notify_updated()
|
||||
return self._cached_tsv, False
|
||||
return fresh, False
|
||||
|
||||
def get_tsv(self, *, force: bool = False, group_id: str | None = None) -> tuple[str, bool]:
|
||||
"""Devuelve (tsv, from_cache) para un equipo o todos (__all__)."""
|
||||
key = _team_cache_key(group_id)
|
||||
|
||||
if force:
|
||||
with self._refresh_lock:
|
||||
if key in self._refreshing_keys:
|
||||
cached = self._get_cached_tsv(key)
|
||||
if cached:
|
||||
return cached, True
|
||||
refreshed, _ = self._refresh(group_id)
|
||||
return refreshed, False
|
||||
|
||||
def get_tsv(self, *, force: bool = False) -> tuple[str, bool]:
|
||||
"""Devuelve (tsv, from_cache). Sirve cache mientras Power Automate responde."""
|
||||
with self._data_lock:
|
||||
cached = self._cached_tsv
|
||||
stale = self._is_stale()
|
||||
cached = self._get_cached_tsv(key)
|
||||
stale = self._is_stale(key)
|
||||
|
||||
if force or stale:
|
||||
if self._should_background_refresh():
|
||||
self._start_background_refresh()
|
||||
if stale and cached:
|
||||
if self._should_background_refresh(key):
|
||||
self._start_background_refresh(group_id)
|
||||
return cached, True
|
||||
|
||||
if cached and not stale:
|
||||
return cached, True
|
||||
|
||||
if cached:
|
||||
return cached, True
|
||||
if self._should_background_refresh(key):
|
||||
self._start_background_refresh(group_id)
|
||||
|
||||
with self._refresh_lock:
|
||||
already_refreshing = self._refreshing
|
||||
if already_refreshing:
|
||||
refreshing = key in self._refreshing_keys
|
||||
if refreshing:
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
with self._data_lock:
|
||||
if self._cached_tsv:
|
||||
return self._cached_tsv, True
|
||||
cached = self._get_cached_tsv(key)
|
||||
if cached:
|
||||
return cached, True
|
||||
time.sleep(0.2)
|
||||
|
||||
return self._refresh()
|
||||
return self._refresh(group_id)
|
||||
|
||||
def get_teams(self, *, force: bool = False) -> list[dict[str, str]]:
|
||||
with self._data_lock:
|
||||
teams = list(self._teams)
|
||||
age = time.time() - self._teams_cached_at
|
||||
if teams and not force and age < self._teams_ttl:
|
||||
return teams
|
||||
fresh = fetch_teams_list()
|
||||
with self._data_lock:
|
||||
self._teams = fresh
|
||||
self._teams_cached_at = time.time()
|
||||
return list(fresh)
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
|
|
@ -280,7 +346,8 @@ def create_handler(
|
|||
def refresh_and_notify() -> None:
|
||||
try:
|
||||
cache._last_background_at = 0.0
|
||||
cache._start_background_refresh()
|
||||
cache._start_background_refresh(None)
|
||||
cache._refresh_stale_entries()
|
||||
print("[WEBHOOK] Actualizacion desde Planner programada.", flush=True)
|
||||
except Exception as exc:
|
||||
event_hub.broadcast("planner-error", json.dumps({"error": str(exc)}))
|
||||
|
|
@ -316,6 +383,10 @@ def create_handler(
|
|||
self._serve_dashboard_tsv(parsed)
|
||||
return
|
||||
|
||||
if route == "/api/groups":
|
||||
self._serve_groups(parsed)
|
||||
return
|
||||
|
||||
if route == "/api/health":
|
||||
self._serve_health()
|
||||
return
|
||||
|
|
@ -392,12 +463,14 @@ def create_handler(
|
|||
self._write_body(body)
|
||||
|
||||
def _serve_dashboard_tsv(self, parsed) -> None:
|
||||
force = "refresh" in parse_qs(parsed.query)
|
||||
query = parse_qs(parsed.query)
|
||||
force = "refresh" in query
|
||||
group_id = query.get("group_id", [""])[0].strip() or None
|
||||
try:
|
||||
tsv, from_cache = cache.get_tsv(force=force)
|
||||
tsv, from_cache = cache.get_tsv(force=force, group_id=group_id)
|
||||
if not tsv.strip():
|
||||
raise RuntimeError(
|
||||
"Sin datos en cache. Power Automate aun no respondio; reintenta en unos segundos."
|
||||
"Sin datos en cache. Planner aun no respondio; reintenta en unos segundos."
|
||||
)
|
||||
body = tsv.encode("utf-8")
|
||||
cache_status = "HIT" if from_cache else "MISS"
|
||||
|
|
@ -406,6 +479,8 @@ def create_handler(
|
|||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Cache", cache_status)
|
||||
if group_id:
|
||||
self.send_header("X-Group-Id", group_id)
|
||||
self.end_headers()
|
||||
self._write_body(body)
|
||||
except _CLIENT_GONE_ERRORS:
|
||||
|
|
@ -422,6 +497,35 @@ def create_handler(
|
|||
except _CLIENT_GONE_ERRORS:
|
||||
pass
|
||||
|
||||
def _serve_groups(self, parsed) -> None:
|
||||
query = parse_qs(parsed.query)
|
||||
force = "refresh" in query
|
||||
try:
|
||||
teams = cache.get_teams(force=force)
|
||||
payload = {"teams": teams}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self._write_body(body)
|
||||
except _CLIENT_GONE_ERRORS:
|
||||
pass
|
||||
except Exception as exc:
|
||||
body = json.dumps(
|
||||
{"teams": [], "error": str(exc)},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
try:
|
||||
self.send_response(502)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self._write_body(body)
|
||||
except _CLIENT_GONE_ERRORS:
|
||||
pass
|
||||
|
||||
def _serve_health(self) -> None:
|
||||
payload = {
|
||||
"ok": True,
|
||||
|
|
@ -587,17 +691,19 @@ def run_server(
|
|||
|
||||
def _warm_cache() -> None:
|
||||
print("[...] Cargando datos iniciales de Planner (puede tardar 1-3 min)...", flush=True)
|
||||
key = _ALL_TEAMS_KEY
|
||||
with cache._refresh_lock:
|
||||
cache._refreshing = True
|
||||
cache._refreshing_keys.add(key)
|
||||
try:
|
||||
cache._last_background_at = 0.0
|
||||
cache._refresh()
|
||||
cache._refresh(None)
|
||||
cache.get_teams()
|
||||
print("[OK] Cache inicial de Planner listo.", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[AVISO] Cache inicial no disponible: {exc}", flush=True)
|
||||
finally:
|
||||
with cache._refresh_lock:
|
||||
cache._refreshing = False
|
||||
cache._refreshing_keys.discard(key)
|
||||
|
||||
threading.Thread(target=_warm_cache, daemon=True).start()
|
||||
|
||||
|
|
@ -605,7 +711,7 @@ def run_server(
|
|||
def _periodic_sync() -> None:
|
||||
while True:
|
||||
time.sleep(DASHBOARD_REFRESH_SECONDS)
|
||||
cache._start_background_refresh()
|
||||
cache._refresh_stale_entries()
|
||||
|
||||
threading.Thread(target=_periodic_sync, daemon=True).start()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ services:
|
|||
DASHBOARD_PORT: "8765"
|
||||
DASHBOARD_OPEN_BROWSER: "false"
|
||||
volumes:
|
||||
- ./config/acta_config.json:/app/config/acta_config.json
|
||||
- ./config:/app/config
|
||||
- ./output:/app/output
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -471,6 +471,48 @@
|
|||
.modal-status { font-size: 0.85rem; margin-top: 8px; min-height: 1.2em; }
|
||||
.modal-status.ok { color: var(--success); }
|
||||
.modal-status.err { color: var(--danger); }
|
||||
|
||||
.team-selector-wrap {
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.team-selector-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255,255,255,0.65);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.team-selector {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.team-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--sapian-accent);
|
||||
box-shadow: 0 0 0 2px rgba(17, 184, 154, 0.25);
|
||||
}
|
||||
.team-selector option {
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
}
|
||||
.team-selector-hint {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-top: 5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -480,6 +522,14 @@
|
|||
<img src="logo_sapian_144.png" alt="Sapian Logo" class="sidebar-logo">
|
||||
|
||||
<h2>📊 Proyectos RTC</h2>
|
||||
|
||||
<div id="teamSelectorWrap" class="team-selector-wrap" style="display:none;">
|
||||
<label class="team-selector-label" for="teamSelector">Equipo de Microsoft Teams</label>
|
||||
<select id="teamSelector" class="team-selector" onchange="cambiarEquipo()" aria-label="Seleccionar equipo">
|
||||
<option value="">Todos los equipos</option>
|
||||
</select>
|
||||
<div class="team-selector-hint" id="teamSelectorHint">Filtra planes y tareas de Planner por equipo.</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-action btn-pdf" onclick="generarPDFComite()">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||
|
|
@ -640,6 +690,8 @@
|
|||
<script src="/config.js"></script>
|
||||
<script>
|
||||
const URL_GOOGLE_SHEET = "/api/dashboard.tsv";
|
||||
const URL_TEAMS = "/api/groups";
|
||||
const TEAM_STORAGE_KEY = "rtc_selected_team_id";
|
||||
const USE_EVENTS = window.DASHBOARD_USE_EVENTS !== false;
|
||||
const REFRESH_MS = window.DASHBOARD_REFRESH_MS || window.DASHBOARD_FALLBACK_MS || 60000;
|
||||
|
||||
|
|
@ -647,7 +699,9 @@
|
|||
let fetchPendienteForce = false;
|
||||
const currentSystemDate = new Date();
|
||||
let proyectosGlobal = [];
|
||||
let estadoFiltroActual = 'activos';
|
||||
let equiposGlobal = [];
|
||||
let equipoSeleccionadoId = "";
|
||||
let estadoFiltroActual = 'activos';
|
||||
|
||||
let chartEstado = null;
|
||||
let chartAvance = null;
|
||||
|
|
@ -954,7 +1008,7 @@
|
|||
}
|
||||
fetchEnCurso = true;
|
||||
try {
|
||||
const url = forceRefresh ? `${URL_GOOGLE_SHEET}?refresh=1` : URL_GOOGLE_SHEET;
|
||||
const url = construirUrlDashboard(forceRefresh);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Fallo en la respuesta de red");
|
||||
const data = await response.text();
|
||||
|
|
@ -977,6 +1031,95 @@
|
|||
}
|
||||
}
|
||||
|
||||
function construirUrlDashboard(forceRefresh) {
|
||||
const params = new URLSearchParams();
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
if (equipoSeleccionadoId) params.set("group_id", equipoSeleccionadoId);
|
||||
const qs = params.toString();
|
||||
return qs ? `${URL_GOOGLE_SHEET}?${qs}` : URL_GOOGLE_SHEET;
|
||||
}
|
||||
|
||||
function nombreEquipoActual() {
|
||||
if (!equipoSeleccionadoId) return "Todos los equipos";
|
||||
const equipo = equiposGlobal.find(e => e.id === equipoSeleccionadoId);
|
||||
return equipo ? equipo.name : "Equipo seleccionado";
|
||||
}
|
||||
|
||||
function actualizarEtiquetaEquipo() {
|
||||
const hint = document.getElementById("teamSelectorHint");
|
||||
if (!hint) return;
|
||||
if (!equiposGlobal.length) {
|
||||
hint.textContent = "";
|
||||
return;
|
||||
}
|
||||
const total = equiposGlobal.length;
|
||||
if (equipoSeleccionadoId) {
|
||||
hint.textContent = `Mostrando Planner de: ${nombreEquipoActual()}`;
|
||||
} else {
|
||||
hint.textContent = `${total} equipo(s) disponibles · vista consolidada`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderizarSelectorEquipos() {
|
||||
const wrap = document.getElementById("teamSelectorWrap");
|
||||
const select = document.getElementById("teamSelector");
|
||||
if (!wrap || !select) return;
|
||||
|
||||
if (!equiposGlobal.length) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.style.display = "block";
|
||||
const valorPrevio = equipoSeleccionadoId || localStorage.getItem(TEAM_STORAGE_KEY) || "";
|
||||
select.innerHTML = '<option value="">Todos los equipos</option>';
|
||||
equiposGlobal.forEach(equipo => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = equipo.id;
|
||||
opt.textContent = equipo.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
const existe = valorPrevio && equiposGlobal.some(e => e.id === valorPrevio);
|
||||
equipoSeleccionadoId = existe ? valorPrevio : "";
|
||||
select.value = equipoSeleccionadoId;
|
||||
if (equipoSeleccionadoId) {
|
||||
localStorage.setItem(TEAM_STORAGE_KEY, equipoSeleccionadoId);
|
||||
} else {
|
||||
localStorage.removeItem(TEAM_STORAGE_KEY);
|
||||
}
|
||||
actualizarEtiquetaEquipo();
|
||||
}
|
||||
|
||||
async function cargarEquipos() {
|
||||
try {
|
||||
const res = await fetch(URL_TEAMS, { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.teams)) {
|
||||
equiposGlobal = data.teams.filter(t => t && t.id);
|
||||
renderizarSelectorEquipos();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("No se pudo cargar la lista de equipos.", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cambiarEquipo() {
|
||||
const select = document.getElementById("teamSelector");
|
||||
if (!select) return;
|
||||
equipoSeleccionadoId = select.value || "";
|
||||
if (equipoSeleccionadoId) {
|
||||
localStorage.setItem(TEAM_STORAGE_KEY, equipoSeleccionadoId);
|
||||
} else {
|
||||
localStorage.removeItem(TEAM_STORAGE_KEY);
|
||||
}
|
||||
actualizarEtiquetaEquipo();
|
||||
mostrarVistaGeneral();
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
fetchGoogleSheet(true);
|
||||
}
|
||||
|
||||
function normalizarId(id) {
|
||||
return String(id || "").trim();
|
||||
}
|
||||
|
|
@ -1022,7 +1165,9 @@
|
|||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
const opcionesFecha = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute:'2-digit' };
|
||||
document.getElementById('fechaActualizacion').innerText = "Última actualización: " + new Date().toLocaleDateString('es-ES', opcionesFecha);
|
||||
const alcance = equiposGlobal.length ? ` · ${nombreEquipoActual()}` : "";
|
||||
document.getElementById('fechaActualizacion').innerText =
|
||||
"Última actualización: " + new Date().toLocaleDateString('es-ES', opcionesFecha) + alcance;
|
||||
|
||||
renderizarListaProyectos();
|
||||
renderizarDashboardGeneral();
|
||||
|
|
@ -1385,9 +1530,10 @@
|
|||
};
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
window.onload = async () => {
|
||||
cargarConfigActa();
|
||||
fetchGoogleSheet(false);
|
||||
await cargarEquipos();
|
||||
fetchGoogleSheet(equipoSeleccionadoId ? true : false);
|
||||
conectarEventosPlanner();
|
||||
if (REFRESH_MS > 0) {
|
||||
setInterval(() => fetchGoogleSheet(true), REFRESH_MS);
|
||||
|
|
|
|||
Loading…
Reference in a new issue