1922 lines
74 KiB
JavaScript
Executable File
1922 lines
74 KiB
JavaScript
Executable File
"use strict";
|
|
|
|
(() => {
|
|
const API_BASE = "/api/v1";
|
|
const STATE_POLL_MS = 5000;
|
|
const MAX_SCHEDULES = 100;
|
|
const MAX_PROFILES = 8;
|
|
const MAX_PATTERNS = 3;
|
|
const MAX_HOLIDAYS = 64;
|
|
const MAX_PHASE_SECONDS = 99;
|
|
const MAX_PATTERN_SECONDS = 600;
|
|
const REQUEST_TIMEOUT_MS = 12000;
|
|
const DAY_NAMES = [
|
|
{ index: 0, short: "D", name: "Domingo" },
|
|
{ index: 1, short: "L", name: "Lunes" },
|
|
{ index: 2, short: "M", name: "Martes" },
|
|
{ index: 3, short: "X", name: "Miércoles" },
|
|
{ index: 4, short: "J", name: "Jueves" },
|
|
{ index: 5, short: "V", name: "Viernes" },
|
|
{ index: 6, short: "S", name: "Sábado" }
|
|
];
|
|
const PHASE_NAMES = ["Alto 1", "Bajo 1", "Alto 2", "Bajo 2", "Alto 3", "Bajo 3"];
|
|
const MONTH_NAMES = [
|
|
"enero", "febrero", "marzo", "abril", "mayo", "junio",
|
|
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"
|
|
];
|
|
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
|
|
|
const refs = {
|
|
app: $("#app"),
|
|
screens: {
|
|
bootstrap: $("#screen-bootstrap"),
|
|
claim: $("#screen-claim"),
|
|
login: $("#screen-login")
|
|
},
|
|
dashboard: $("#dashboard"),
|
|
bootstrapMessage: $("#bootstrap-message"),
|
|
bootstrapError: $("#bootstrap-error"),
|
|
bootstrapErrorMessage: $("#bootstrap-error-message"),
|
|
bootstrapRetry: $("#bootstrap-retry"),
|
|
claimForm: $("#claim-form"),
|
|
claimToken: $("#claim-token"),
|
|
claimPassword: $("#claim-password"),
|
|
claimPasswordConfirm: $("#claim-password-confirm"),
|
|
claimError: $("#claim-error"),
|
|
claimSubmit: $("#claim-submit"),
|
|
loginForm: $("#login-form"),
|
|
loginPassword: $("#login-password"),
|
|
loginIntro: $("#login-intro"),
|
|
loginError: $("#login-error"),
|
|
loginSubmit: $("#login-submit"),
|
|
logoutButton: $("#logout-button"),
|
|
refreshButton: $("#refresh-data"),
|
|
csrfWarning: $("#csrf-warning"),
|
|
connectionBadge: $("#connection-badge"),
|
|
connectionText: $("#connection-text"),
|
|
clockValue: $("#clock-value"),
|
|
clockDate: $("#clock-date"),
|
|
clockStatus: $("#clock-status"),
|
|
clockIndicator: $("#clock-indicator"),
|
|
syncTimeButton: $("#sync-time-button"),
|
|
sequenceIndicator: $("#sequence-indicator"),
|
|
activePatternLabel: $("#active-pattern-label"),
|
|
stateMessage: $("#state-message"),
|
|
stopButton: $("#stop-button"),
|
|
addProfileButton: $("#add-profile-button"),
|
|
activeProfileForm: $("#active-profile-form"),
|
|
activeProfileSelect: $("#active-profile-select"),
|
|
activeProfileSubmit: $("#active-profile-submit"),
|
|
profilesContent: $("#profiles-content"),
|
|
addScheduleButton: $("#add-schedule-button"),
|
|
profileTabs: $("#profile-tabs"),
|
|
scheduleCount: $("#schedule-count"),
|
|
schedulesContent: $("#schedules-content"),
|
|
addPatternButton: $("#add-pattern-button"),
|
|
patternsContent: $("#patterns-content"),
|
|
holidayForm: $("#holiday-form"),
|
|
holidayMonth: $("#holiday-month"),
|
|
holidayDay: $("#holiday-day"),
|
|
holidaySubmit: $("#holiday-submit"),
|
|
holidayError: $("#holiday-error"),
|
|
holidaysContent: $("#holidays-content"),
|
|
profileDialog: $("#profile-dialog"),
|
|
profileForm: $("#profile-form"),
|
|
profileDialogTitle: $("#profile-dialog-title"),
|
|
profileId: $("#profile-id"),
|
|
profileName: $("#profile-name"),
|
|
profileFormError: $("#profile-form-error"),
|
|
profileSubmit: $("#profile-submit"),
|
|
scheduleDialog: $("#schedule-dialog"),
|
|
scheduleForm: $("#schedule-form"),
|
|
scheduleDialogTitle: $("#schedule-dialog-title"),
|
|
scheduleId: $("#schedule-id"),
|
|
scheduleTime: $("#schedule-time"),
|
|
scheduleProfile: $("#schedule-profile"),
|
|
schedulePattern: $("#schedule-pattern"),
|
|
scheduleDays: $("#schedule-days"),
|
|
scheduleFormError: $("#schedule-form-error"),
|
|
scheduleSubmit: $("#schedule-submit"),
|
|
patternDialog: $("#pattern-dialog"),
|
|
patternForm: $("#pattern-form"),
|
|
patternDialogTitle: $("#pattern-dialog-title"),
|
|
patternId: $("#pattern-id"),
|
|
patternCode: $("#pattern-code"),
|
|
patternPhases: $("#pattern-phases"),
|
|
patternEnabled: $("#pattern-enabled"),
|
|
patternFormError: $("#pattern-form-error"),
|
|
patternSubmit: $("#pattern-submit"),
|
|
confirmDialog: $("#confirm-dialog"),
|
|
confirmTitle: $("#confirm-title"),
|
|
confirmMessage: $("#confirm-message"),
|
|
confirmActionButton: $("#confirm-action-button"),
|
|
toastRegion: $("#toast-region")
|
|
};
|
|
|
|
const appState = {
|
|
authenticated: false,
|
|
csrfToken: null,
|
|
selectedProfileId: null,
|
|
activeProfileId: null,
|
|
activeProfileKnown: false,
|
|
timeSyncAttempted: false,
|
|
status: null,
|
|
profiles: [],
|
|
schedules: [],
|
|
patterns: [],
|
|
holidays: [],
|
|
resources: {
|
|
profiles: { loading: true, error: null },
|
|
schedules: { loading: true, error: null },
|
|
patterns: { loading: true, error: null },
|
|
holidays: { loading: true, error: null }
|
|
}
|
|
};
|
|
|
|
let statePollTimer = null;
|
|
let clockTimer = null;
|
|
let stateRequestInFlight = false;
|
|
let confirmHandler = null;
|
|
|
|
class ApiError extends Error {
|
|
constructor(message, status = 0, code = "") {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function createElement(tagName, className = "", text = null) {
|
|
const node = document.createElement(tagName);
|
|
if (className) node.className = className;
|
|
if (text !== null && text !== undefined) node.textContent = String(text);
|
|
return node;
|
|
}
|
|
|
|
function setHidden(node, hidden) {
|
|
node.hidden = Boolean(hidden);
|
|
}
|
|
|
|
function firstDefined(...values) {
|
|
return values.find((value) => value !== undefined && value !== null);
|
|
}
|
|
|
|
function asString(value) {
|
|
if (value === undefined || value === null) return "";
|
|
return String(value);
|
|
}
|
|
|
|
function asFiniteNumber(value) {
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
if (typeof value !== "string" || value.trim() === "") return null;
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
|
|
function asBoolean(value) {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "number") return value !== 0;
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (["true", "1", "yes", "sí", "si", "valid", "válido", "active", "activo"].includes(normalized)) return true;
|
|
if (["false", "0", "no", "invalid", "inválido", "inactive", "inactivo"].includes(normalized)) return false;
|
|
return null;
|
|
}
|
|
|
|
function sameId(left, right) {
|
|
return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right);
|
|
}
|
|
|
|
function findById(items, id) {
|
|
return items.find((item) => sameId(item.id, id)) || null;
|
|
}
|
|
|
|
function extractData(payload, aliases = []) {
|
|
let candidate = payload;
|
|
for (let depth = 0; depth < 3; depth += 1) {
|
|
if (Array.isArray(candidate) || candidate === null || candidate === undefined) return candidate;
|
|
if (typeof candidate !== "object") return candidate;
|
|
for (const alias of aliases) {
|
|
if (Object.prototype.hasOwnProperty.call(candidate, alias)) {
|
|
candidate = candidate[alias];
|
|
break;
|
|
}
|
|
}
|
|
if (!Array.isArray(candidate) && typeof candidate === "object" && Object.prototype.hasOwnProperty.call(candidate, "data")) {
|
|
candidate = candidate.data;
|
|
continue;
|
|
}
|
|
return candidate;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function extractList(payload, aliases, label) {
|
|
let candidate = payload;
|
|
for (let depth = 0; depth < 4; depth += 1) {
|
|
if (Array.isArray(candidate)) return candidate;
|
|
if (candidate === null || candidate === undefined || typeof candidate !== "object") break;
|
|
const alias = aliases.find((name) => Object.prototype.hasOwnProperty.call(candidate, name));
|
|
if (alias) {
|
|
candidate = candidate[alias];
|
|
continue;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(candidate, "data")) {
|
|
candidate = candidate.data;
|
|
continue;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(candidate, "items")) {
|
|
candidate = candidate.items;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
throw new ApiError(`La respuesta de ${label} no contiene una lista válida.`);
|
|
}
|
|
|
|
function responseMetadata(payload) {
|
|
const metadata = [];
|
|
let current = payload;
|
|
for (let depth = 0; depth < 3 && current && typeof current === "object"; depth += 1) {
|
|
if (!Array.isArray(current)) metadata.push(current);
|
|
current = current.data;
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
function metadataValue(payload, aliases) {
|
|
for (const object of responseMetadata(payload)) {
|
|
for (const alias of aliases) {
|
|
if (Object.prototype.hasOwnProperty.call(object, alias)) return object[alias];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function errorMessageFromPayload(payload, response) {
|
|
if (payload && typeof payload === "object") {
|
|
if (typeof payload.message === "string" && payload.message.trim()) return payload.message;
|
|
if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string") {
|
|
return payload.error.message;
|
|
}
|
|
if (typeof payload.error === "string" && payload.error.trim()) return payload.error;
|
|
}
|
|
if (response.status === 400) return "El dispositivo rechazó los datos enviados.";
|
|
if (response.status === 401) return "La sesión no es válida o ha expirado.";
|
|
if (response.status === 403) return "No se pudo validar la sesión de protección.";
|
|
if (response.status === 404) return "El recurso solicitado no está disponible.";
|
|
if (response.status === 409) return "La operación entra en conflicto con el estado actual.";
|
|
if (response.status >= 500) return "El dispositivo no pudo completar la operación.";
|
|
return `La solicitud falló con el código ${response.status}.`;
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const method = String(options.method || "GET").toUpperCase();
|
|
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(method);
|
|
const isAuthenticatedMutation = isMutation && appState.authenticated;
|
|
|
|
if (isAuthenticatedMutation && !appState.csrfToken) {
|
|
throw new ApiError("No hay un token de protección en memoria. Actualiza la sesión antes de modificar datos.");
|
|
}
|
|
|
|
const headers = { Accept: "application/json" };
|
|
if (options.body !== undefined) headers["Content-Type"] = "application/json";
|
|
if (isAuthenticatedMutation) headers["X-CSRF-Token"] = appState.csrfToken;
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
let response;
|
|
let payload = null;
|
|
|
|
try {
|
|
const requestOptions = {
|
|
method,
|
|
headers,
|
|
credentials: "same-origin",
|
|
cache: "no-store",
|
|
signal: controller.signal
|
|
};
|
|
if (options.body !== undefined) requestOptions.body = JSON.stringify(options.body);
|
|
response = await fetch(`${API_BASE}${path}`, requestOptions);
|
|
const rawText = await response.text();
|
|
if (rawText) {
|
|
try {
|
|
payload = JSON.parse(rawText);
|
|
} catch (_error) {
|
|
if (response.ok) throw new ApiError("El dispositivo devolvió una respuesta que no es JSON válido.", response.status);
|
|
payload = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ApiError) throw error;
|
|
if (error && error.name === "AbortError") {
|
|
throw new ApiError("El dispositivo tardó demasiado en responder.", 0, "timeout");
|
|
}
|
|
throw new ApiError("No se pudo conectar con TimbreESP. Comprueba que el equipo esté accesible.", 0, "network");
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
|
|
const payloadFailed = payload && typeof payload === "object" && payload.ok === false;
|
|
if (!response.ok || payloadFailed) {
|
|
const code = payload && typeof payload === "object" && payload.error && typeof payload.error === "object"
|
|
? asString(payload.error.code)
|
|
: asString(payload && typeof payload === "object" ? payload.error : "");
|
|
throw new ApiError(errorMessageFromPayload(payload, response), response.status, code);
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function setFormError(node, message = "") {
|
|
node.textContent = message;
|
|
setHidden(node, !message);
|
|
}
|
|
|
|
function setButtonBusy(button, busy, busyText = "Guardando…") {
|
|
if (!button) return;
|
|
if (busy) {
|
|
button.dataset.idleText = button.textContent;
|
|
button.dataset.wasDisabled = button.disabled ? "true" : "false";
|
|
button.textContent = busyText;
|
|
button.disabled = true;
|
|
button.setAttribute("aria-busy", "true");
|
|
button.classList.add("is-busy");
|
|
} else {
|
|
if (button.dataset.idleText !== undefined) button.textContent = button.dataset.idleText;
|
|
button.disabled = button.dataset.wasDisabled === "true";
|
|
button.removeAttribute("aria-busy");
|
|
button.classList.remove("is-busy");
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = "success") {
|
|
const toast = createElement("div", `toast toast--${type}`, message);
|
|
toast.setAttribute("role", type === "error" ? "alert" : "status");
|
|
refs.toastRegion.append(toast);
|
|
window.setTimeout(() => toast.remove(), type === "error" ? 7000 : 4200);
|
|
}
|
|
|
|
function setConnection(kind, text) {
|
|
refs.connectionBadge.className = `connection-badge connection-badge--${kind}`;
|
|
refs.connectionText.textContent = text;
|
|
}
|
|
|
|
function showScreen(name) {
|
|
for (const [key, screen] of Object.entries(refs.screens)) setHidden(screen, key !== name);
|
|
setHidden(refs.dashboard, name !== "dashboard");
|
|
}
|
|
|
|
function showBootstrapError(error) {
|
|
refs.bootstrapMessage.textContent = "No se pudo iniciar la interfaz.";
|
|
refs.bootstrapErrorMessage.textContent = error instanceof Error ? error.message : "Ha ocurrido un error inesperado.";
|
|
setHidden(refs.bootstrapError, false);
|
|
}
|
|
|
|
function stopTimers() {
|
|
if (statePollTimer !== null) window.clearInterval(statePollTimer);
|
|
if (clockTimer !== null) window.clearInterval(clockTimer);
|
|
statePollTimer = null;
|
|
clockTimer = null;
|
|
stateRequestInFlight = false;
|
|
}
|
|
|
|
function startTimers() {
|
|
stopTimers();
|
|
clockTimer = window.setInterval(renderClock, 1000);
|
|
statePollTimer = window.setInterval(() => void pollStatus(false), STATE_POLL_MS);
|
|
}
|
|
|
|
function updateSessionFromPayload(payload) {
|
|
const session = extractData(payload, ["session"]) || {};
|
|
const authenticated = asBoolean(session.authenticated) === true;
|
|
const csrfToken = asString(session.csrfToken || session.csrf_token);
|
|
appState.authenticated = authenticated;
|
|
appState.csrfToken = authenticated && csrfToken ? csrfToken : null;
|
|
setHidden(refs.csrfWarning, !authenticated || Boolean(appState.csrfToken));
|
|
return { authenticated, setupRequired: asBoolean(session.setupRequired) === true };
|
|
}
|
|
|
|
function resetDashboardData() {
|
|
appState.status = null;
|
|
appState.selectedProfileId = null;
|
|
appState.activeProfileId = null;
|
|
appState.activeProfileKnown = false;
|
|
appState.profiles = [];
|
|
appState.schedules = [];
|
|
appState.patterns = [];
|
|
appState.holidays = [];
|
|
for (const resource of Object.values(appState.resources)) {
|
|
resource.loading = true;
|
|
resource.error = null;
|
|
}
|
|
for (const container of [refs.profilesContent, refs.schedulesContent, refs.patternsContent, refs.holidaysContent]) {
|
|
container.setAttribute("aria-busy", "true");
|
|
container.replaceChildren();
|
|
}
|
|
refs.profileTabs.replaceChildren();
|
|
refs.scheduleCount.textContent = `0 / ${MAX_SCHEDULES} horarios`;
|
|
refs.addProfileButton.disabled = true;
|
|
refs.addScheduleButton.disabled = true;
|
|
refs.addPatternButton.disabled = true;
|
|
refs.holidaySubmit.disabled = true;
|
|
renderLoading(refs.profilesContent, "Cargando perfiles…");
|
|
renderLoading(refs.schedulesContent, "Cargando horarios…");
|
|
renderLoading(refs.patternsContent, "Cargando patrones…");
|
|
renderLoading(refs.holidaysContent, "Cargando feriados…");
|
|
renderStatus();
|
|
}
|
|
|
|
function showClaim(message = "") {
|
|
stopTimers();
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
resetDashboardData();
|
|
showScreen("claim");
|
|
setFormError(refs.claimError, message);
|
|
window.setTimeout(() => refs.claimToken.focus(), 0);
|
|
}
|
|
|
|
function showLogin(message = "") {
|
|
stopTimers();
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
resetDashboardData();
|
|
showScreen("login");
|
|
refs.loginPassword.value = "";
|
|
refs.loginIntro.textContent = "Introduce la contraseña configurada para controlar el timbre.";
|
|
setFormError(refs.loginError, message);
|
|
window.setTimeout(() => refs.loginPassword.focus(), 0);
|
|
}
|
|
|
|
function handleSessionExpired(message = "La sesión expiró. Vuelve a iniciar sesión.") {
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
showLogin(message);
|
|
}
|
|
|
|
function showDashboard(session) {
|
|
appState.timeSyncAttempted = false;
|
|
if (session && session.setupRequired) {
|
|
showClaim();
|
|
return;
|
|
}
|
|
resetDashboardData();
|
|
showScreen("dashboard");
|
|
setHidden(refs.csrfWarning, Boolean(appState.csrfToken));
|
|
setConnection("loading", "Conectando");
|
|
startTimers();
|
|
void pollStatus(true);
|
|
void loadAllData();
|
|
}
|
|
|
|
async function bootstrap() {
|
|
stopTimers();
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
showScreen("bootstrap");
|
|
refs.app.setAttribute("aria-busy", "true");
|
|
refs.bootstrapMessage.textContent = "Comprobando el estado de la configuración.";
|
|
setHidden(refs.bootstrapError, true);
|
|
|
|
try {
|
|
const setupPayload = await api("/setup");
|
|
const setup = extractData(setupPayload, ["setup"]) || {};
|
|
const setupRequired = asBoolean(firstDefined(setup.setupRequired, setup.required, setup.claimRequired));
|
|
if (setupRequired === true) {
|
|
showClaim();
|
|
return;
|
|
}
|
|
|
|
const sessionPayload = await api("/session");
|
|
const session = updateSessionFromPayload(sessionPayload);
|
|
if (session.setupRequired) {
|
|
showClaim();
|
|
} else if (session.authenticated) {
|
|
showDashboard(session);
|
|
} else {
|
|
showLogin();
|
|
}
|
|
} catch (error) {
|
|
showScreen("bootstrap");
|
|
showBootstrapError(error);
|
|
} finally {
|
|
refs.app.setAttribute("aria-busy", "false");
|
|
}
|
|
}
|
|
|
|
async function handleClaimSubmit(event) {
|
|
event.preventDefault();
|
|
setFormError(refs.claimError);
|
|
if (!refs.claimForm.reportValidity()) return;
|
|
if (refs.claimPassword.value !== refs.claimPasswordConfirm.value) {
|
|
setFormError(refs.claimError, "Las contraseñas no coinciden.");
|
|
return;
|
|
}
|
|
|
|
setButtonBusy(refs.claimSubmit, true, "Claim…");
|
|
try {
|
|
await api("/setup/claim", {
|
|
method: "POST",
|
|
body: {
|
|
token: refs.claimToken.value.trim(),
|
|
password: refs.claimPassword.value
|
|
}
|
|
});
|
|
refs.claimForm.reset();
|
|
let sessionPayload;
|
|
try {
|
|
sessionPayload = await api("/session");
|
|
} catch (_error) {
|
|
sessionPayload = null;
|
|
}
|
|
if (sessionPayload) {
|
|
const session = updateSessionFromPayload(sessionPayload);
|
|
if (session.authenticated) {
|
|
showDashboard(session);
|
|
showToast("Configuración inicial completada.");
|
|
return;
|
|
}
|
|
}
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
showLogin("Configuración completada. Inicia sesión con la contraseña nueva.");
|
|
} catch (error) {
|
|
setFormError(refs.claimError, error instanceof Error ? error.message : "No se pudo completar el Claim.");
|
|
} finally {
|
|
setButtonBusy(refs.claimSubmit, false);
|
|
}
|
|
}
|
|
|
|
async function handleLoginSubmit(event) {
|
|
event.preventDefault();
|
|
setFormError(refs.loginError);
|
|
if (!refs.loginForm.reportValidity()) return;
|
|
|
|
setButtonBusy(refs.loginSubmit, true, "Entrando…");
|
|
try {
|
|
const loginPayload = await api("/session", {
|
|
method: "POST",
|
|
body: { password: refs.loginPassword.value }
|
|
});
|
|
const directSession = updateSessionFromPayload(loginPayload);
|
|
if (directSession.authenticated && appState.csrfToken) {
|
|
refs.loginForm.reset();
|
|
showDashboard(directSession);
|
|
return;
|
|
}
|
|
|
|
const sessionPayload = await api("/session");
|
|
const session = updateSessionFromPayload(sessionPayload);
|
|
if (session.setupRequired) {
|
|
showClaim();
|
|
} else if (session.authenticated) {
|
|
refs.loginForm.reset();
|
|
showDashboard(session);
|
|
} else {
|
|
setFormError(refs.loginError, "La contraseña no fue aceptada.");
|
|
}
|
|
} catch (error) {
|
|
setFormError(refs.loginError, error instanceof Error ? error.message : "No se pudo iniciar sesión.");
|
|
} finally {
|
|
setButtonBusy(refs.loginSubmit, false);
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
setButtonBusy(refs.logoutButton, true, "Cerrando…");
|
|
try {
|
|
await api("/session", { method: "DELETE" });
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
showLogin("Sesión cerrada.");
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
appState.authenticated = false;
|
|
appState.csrfToken = null;
|
|
showLogin();
|
|
} else {
|
|
showToast(error instanceof Error ? error.message : "No se pudo cerrar la sesión.", "error");
|
|
}
|
|
} finally {
|
|
setButtonBusy(refs.logoutButton, false);
|
|
}
|
|
}
|
|
|
|
function normalizeDayMask(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.reduce((mask, day) => {
|
|
if (typeof day === "number" && day >= 0 && day <= 6) return mask | (1 << day);
|
|
const legacyIndex = DAY_NAMES.findIndex((entry) => entry.short.toLowerCase() === asString(day).trim().toLowerCase());
|
|
return legacyIndex >= 0 ? mask | (1 << legacyIndex) : mask;
|
|
}, 0);
|
|
}
|
|
|
|
const number = asFiniteNumber(value);
|
|
if (number !== null) {
|
|
if (String(value).trim().toLowerCase().startsWith("0b")) {
|
|
const binary = Number.parseInt(String(value).slice(2), 2);
|
|
return Number.isInteger(binary) ? binary & 0x7f : 0;
|
|
}
|
|
return Number.isInteger(number) ? number & 0x7f : 0;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const legacyCodes = value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
return legacyCodes.reduce((mask, code) => {
|
|
const index = DAY_NAMES.findIndex((entry) => entry.short.toLowerCase() === code.toLowerCase());
|
|
return index >= 0 ? mask | (1 << index) : mask;
|
|
}, 0);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function parseProfiles(payload) {
|
|
const items = extractList(payload, ["profiles", "perfiles"], "perfiles");
|
|
const profiles = items.map((item, index) => ({
|
|
raw: item,
|
|
id: firstDefined(item.id, item.profileId, item.perfilId, index),
|
|
name: asString(firstDefined(item.name, item.nombre, item.label) || `Perfil ${index + 1}`)
|
|
}));
|
|
const activeValue = metadataValue(payload, [
|
|
"activeProfileId", "activeProfile", "perfilActivoId", "perfilActivo", "activo"
|
|
]);
|
|
return { profiles, activeProfileId: activeValue ?? null, activeProfileKnown: activeValue !== undefined };
|
|
}
|
|
|
|
function minuteToTime(value) {
|
|
const minute = asFiniteNumber(value);
|
|
if (minute === null || !Number.isInteger(minute) || minute < 0 || minute > 1439) {
|
|
return "00:00";
|
|
}
|
|
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
function parseSchedules(payload) {
|
|
const items = extractList(payload, ["schedules", "horarios"], "horarios");
|
|
return items.map((item, index) => ({
|
|
raw: item,
|
|
id: firstDefined(item.id, item.scheduleId, item.horarioId, index),
|
|
profileId: firstDefined(item.profileId, item.perfilId, item.profile, item.perfil),
|
|
patternId: firstDefined(item.patternId, item.patronId, item.pattern, item.patron),
|
|
time: firstDefined(item.time, item.hora) !== undefined
|
|
? asString(firstDefined(item.time, item.hora))
|
|
: minuteToTime(item.minuteOfDay),
|
|
days: normalizeDayMask(firstDefined(item.days, item.weekdays, item.dayMask, item.dias, 0)),
|
|
enabled: asBoolean(firstDefined(item.enabled, true)) !== false
|
|
}));
|
|
}
|
|
|
|
function parsePatterns(payload) {
|
|
const items = extractList(payload, ["patterns", "patrones"], "patrones");
|
|
return items.map((item, index) => {
|
|
const phases = Array.isArray(item.phaseSeconds)
|
|
? item.phaseSeconds
|
|
: (Array.isArray(item.fases) ? item.fases : []);
|
|
const phaseSeconds = Array.from({ length: 6 }, (_, phaseIndex) => {
|
|
const value = asFiniteNumber(phases[phaseIndex]);
|
|
return value === null || value < 0 ? 0 : value;
|
|
});
|
|
return {
|
|
raw: item,
|
|
id: firstDefined(item.id, item.patternId, index),
|
|
legacyCode: asString(firstDefined(item.legacyCode, item.code, `Patrón ${index + 1}`)).toUpperCase(),
|
|
target: firstDefined(item.target, "GPIO22"),
|
|
phaseSeconds,
|
|
enabled: asBoolean(firstDefined(item.enabled, true)) !== false
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseHolidays(payload) {
|
|
const items = extractList(payload, ["holidays", "feriados"], "feriados");
|
|
return items
|
|
.map((item) => ({
|
|
raw: item,
|
|
month: asFiniteNumber(firstDefined(item.month, item.mes)),
|
|
day: asFiniteNumber(firstDefined(item.day, item.dia))
|
|
}))
|
|
.filter((holiday) => holiday.month !== null && holiday.day !== null)
|
|
.map((holiday) => ({ ...holiday, month: Math.trunc(holiday.month), day: Math.trunc(holiday.day) }))
|
|
.sort((left, right) => left.month - right.month || left.day - right.day);
|
|
}
|
|
|
|
function normalizeEpoch(value) {
|
|
const epoch = asFiniteNumber(value);
|
|
if (epoch === null) return null;
|
|
const seconds = epoch > 10_000_000_000 ? epoch / 1000 : epoch;
|
|
return Number.isInteger(seconds) ? seconds : Math.trunc(seconds);
|
|
}
|
|
|
|
function parseStatus(payload) {
|
|
const root = extractData(payload, ["estado", "state"]) || {};
|
|
if (typeof root !== "object" || Array.isArray(root)) {
|
|
return { clockValid: false, sequenceActive: false, epoch: null };
|
|
}
|
|
const clock = root.clock && typeof root.clock === "object" ? root.clock : {};
|
|
const clockEs = root.reloj && typeof root.reloj === "object" ? root.reloj : {};
|
|
const epoch = normalizeEpoch(firstDefined(
|
|
root.epoch, root.timeEpoch, root.unixTime, root.horaEpoch,
|
|
typeof root.reloj === "number" ? root.reloj : undefined,
|
|
typeof root.hora === "number" ? root.hora : undefined,
|
|
clock.epoch, clockEs.epoch
|
|
));
|
|
const validFlag = asBoolean(firstDefined(
|
|
root.clockValid, root.timeValid, root.hasValidTime, root.relojValido, root.horaValida,
|
|
clock.valid, clock.clockValid, clockEs.valido, clockEs.valid
|
|
));
|
|
const plausibleEpoch = epoch !== null && epoch >= 1_577_836_800 && epoch <= 4_102_444_800;
|
|
const sequenceFlag = asBoolean(firstDefined(
|
|
root.sequenceActive, root.secuenciaActiva, root.ringing, root.sonando,
|
|
root.outputActive, root.salidaActiva, root.patternRunning, root.patronEnEjecucion,
|
|
root.actionActive, root.accionActiva
|
|
));
|
|
return {
|
|
epoch,
|
|
clockValid: validFlag === null ? plausibleEpoch : validFlag,
|
|
sequenceActive: sequenceFlag === true,
|
|
activePatternId: firstDefined(
|
|
root.activePatternId, root.currentPatternId, root.patternId,
|
|
root.patronActivoId, root.patronActualId, root.patronId
|
|
) ?? null,
|
|
activeProfileId: firstDefined(
|
|
root.activeProfileId, root.perfilActivoId, root.profileId, root.perfilId
|
|
) ?? null,
|
|
wifiConnected: asBoolean(firstDefined(root.wifiConnected, root.wifiConectado, root.online))
|
|
};
|
|
}
|
|
|
|
function renderClock() {
|
|
if (!appState.status) return;
|
|
if (!appState.status.clockValid || appState.status.epoch === null) {
|
|
refs.clockValue.textContent = "--:--:--";
|
|
refs.clockValue.removeAttribute("datetime");
|
|
refs.clockDate.textContent = "Hora no disponible";
|
|
return;
|
|
}
|
|
const date = new Date(appState.status.epoch * 1000);
|
|
if (Number.isNaN(date.getTime())) {
|
|
refs.clockValue.textContent = "--:--:--";
|
|
refs.clockValue.removeAttribute("datetime");
|
|
refs.clockDate.textContent = "Hora no disponible";
|
|
return;
|
|
}
|
|
try {
|
|
const time = new Intl.DateTimeFormat("es-ES", {
|
|
timeZone: "Etc/GMT+6",
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23"
|
|
}).format(date);
|
|
const fullDate = new Intl.DateTimeFormat("es-ES", {
|
|
timeZone: "Etc/GMT+6",
|
|
weekday: "long", year: "numeric", month: "long", day: "2-digit"
|
|
}).format(date);
|
|
refs.clockValue.textContent = time;
|
|
refs.clockValue.setAttribute("datetime", date.toISOString());
|
|
refs.clockDate.textContent = fullDate;
|
|
} catch (_error) {
|
|
refs.clockValue.textContent = "--:--:--";
|
|
refs.clockValue.removeAttribute("datetime");
|
|
refs.clockDate.textContent = "Hora no disponible";
|
|
}
|
|
}
|
|
|
|
function renderStatus() {
|
|
if (!appState.status) {
|
|
refs.clockIndicator.textContent = "Inválido";
|
|
refs.clockIndicator.className = "state-pill state-pill--invalid";
|
|
refs.clockValue.textContent = "--:--:--";
|
|
refs.clockValue.removeAttribute("datetime");
|
|
refs.clockDate.textContent = "Hora no disponible";
|
|
refs.clockStatus.textContent = "No se ha podido validar el reloj. Se considera inválido hasta recibir una hora válida.";
|
|
refs.clockStatus.className = "status-message status-message--error";
|
|
refs.sequenceIndicator.textContent = "Desconocido";
|
|
refs.sequenceIndicator.className = "state-pill state-pill--muted";
|
|
refs.stopButton.disabled = true;
|
|
return;
|
|
}
|
|
|
|
if (appState.status.clockValid && appState.status.epoch !== null) {
|
|
refs.clockIndicator.textContent = "Válido";
|
|
refs.clockIndicator.className = "state-pill state-pill--valid";
|
|
refs.clockStatus.textContent = "Reloj válido. El estado se consulta cada 5 segundos.";
|
|
refs.clockStatus.className = "status-message status-message--success";
|
|
} else {
|
|
refs.clockIndicator.textContent = "Inválido";
|
|
refs.clockIndicator.className = "state-pill state-pill--invalid";
|
|
refs.clockStatus.textContent = "Reloj inválido. Sincroniza la hora antes de confiar en la programación.";
|
|
refs.clockStatus.className = "status-message status-message--error";
|
|
}
|
|
renderClock();
|
|
|
|
const activePattern = findById(appState.patterns, appState.status.activePatternId);
|
|
refs.activePatternLabel.textContent = activePattern
|
|
? `${activePattern.legacyCode} · ${activePattern.enabled ? "habilitado" : "deshabilitado"}`
|
|
: (appState.status.activePatternId !== null ? `Patrón ${appState.status.activePatternId}` : "Ninguno");
|
|
if (appState.status.sequenceActive) {
|
|
refs.sequenceIndicator.textContent = "Activo";
|
|
refs.sequenceIndicator.className = "state-pill state-pill--active";
|
|
} else {
|
|
refs.sequenceIndicator.textContent = "Inactivo";
|
|
refs.sequenceIndicator.className = "state-pill state-pill--muted";
|
|
}
|
|
refs.stopButton.disabled = !appState.status.sequenceActive;
|
|
}
|
|
|
|
async function pollStatus(initial) {
|
|
if (!appState.authenticated || stateRequestInFlight || refs.dashboard.hidden) return;
|
|
const requestToken = appState.csrfToken;
|
|
stateRequestInFlight = true;
|
|
if (initial) {
|
|
setConnection("loading", "Sincronizando");
|
|
refs.stateMessage.textContent = "Consultando el estado…";
|
|
refs.stateMessage.className = "status-message status-message--muted";
|
|
}
|
|
|
|
try {
|
|
const payload = await api("/estado");
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return;
|
|
appState.status = parseStatus(payload);
|
|
if (!appState.status.clockValid && !appState.timeSyncAttempted) {
|
|
appState.timeSyncAttempted = true;
|
|
try {
|
|
await api("/hora", {
|
|
method: "POST",
|
|
body: { epoch: Math.floor(Date.now() / 1000) }
|
|
});
|
|
const refreshed = await api("/estado");
|
|
if (!refs.dashboard.hidden && appState.csrfToken === requestToken) {
|
|
appState.status = parseStatus(refreshed);
|
|
}
|
|
} catch (syncError) {
|
|
refs.clockStatus.textContent = syncError instanceof Error
|
|
? syncError.message
|
|
: "No se pudo sincronizar la hora automáticamente.";
|
|
}
|
|
}
|
|
if (appState.status.activeProfileId !== null) {
|
|
appState.activeProfileId = appState.status.activeProfileId;
|
|
appState.activeProfileKnown = true;
|
|
renderActiveProfileControl();
|
|
renderProfiles();
|
|
}
|
|
setConnection("online", "En línea");
|
|
refs.stateMessage.textContent = `Actualizado a las ${new Intl.DateTimeFormat("es-ES", {
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23"
|
|
}).format(new Date())}`;
|
|
refs.stateMessage.className = "status-message status-message--success";
|
|
renderStatus();
|
|
} catch (error) {
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return;
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
return;
|
|
}
|
|
setConnection("error", "Sin respuesta");
|
|
if (!appState.status) {
|
|
refs.clockIndicator.textContent = "Inválido";
|
|
refs.clockIndicator.className = "state-pill state-pill--invalid";
|
|
refs.clockValue.textContent = "--:--:--";
|
|
refs.clockDate.textContent = "Hora no disponible";
|
|
refs.clockStatus.textContent = "No se pudo consultar el reloj. Se considera inválido hasta recibir una respuesta válida.";
|
|
refs.clockStatus.className = "status-message status-message--error";
|
|
}
|
|
refs.stateMessage.textContent = error instanceof Error ? error.message : "No se pudo consultar el estado.";
|
|
refs.stateMessage.className = "status-message status-message--error";
|
|
renderStatus();
|
|
} finally {
|
|
if (appState.csrfToken === requestToken) stateRequestInFlight = false;
|
|
}
|
|
}
|
|
|
|
function renderLoading(container, message) {
|
|
container.setAttribute("aria-busy", "true");
|
|
const wrapper = createElement("div", "resource-state");
|
|
const spinner = createElement("span", "spinner");
|
|
spinner.setAttribute("aria-hidden", "true");
|
|
const text = createElement("p", "", message);
|
|
wrapper.append(spinner, text);
|
|
container.replaceChildren(wrapper);
|
|
}
|
|
|
|
function renderResourceError(container, title, error, retry) {
|
|
container.setAttribute("aria-busy", "false");
|
|
const wrapper = createElement("div", "resource-state resource-state--error");
|
|
wrapper.append(createElement("h3", "", title));
|
|
wrapper.append(createElement("p", "", error instanceof Error ? error.message : "Ha ocurrido un error inesperado."));
|
|
const button = createElement("button", "button button--secondary", "Reintentar");
|
|
button.type = "button";
|
|
button.addEventListener("click", retry);
|
|
wrapper.append(button);
|
|
container.replaceChildren(wrapper);
|
|
}
|
|
|
|
function renderEmpty(container, title, message, actionLabel, action) {
|
|
container.setAttribute("aria-busy", "false");
|
|
const wrapper = createElement("div", "resource-state");
|
|
wrapper.append(createElement("h3", "", title));
|
|
wrapper.append(createElement("p", "", message));
|
|
if (actionLabel && action) {
|
|
const button = createElement("button", "button button--secondary", actionLabel);
|
|
button.type = "button";
|
|
button.addEventListener("click", action);
|
|
wrapper.append(button);
|
|
}
|
|
container.replaceChildren(wrapper);
|
|
}
|
|
|
|
async function loadProfiles() {
|
|
if (!appState.authenticated || refs.dashboard.hidden) return false;
|
|
const requestToken = appState.csrfToken;
|
|
const resource = appState.resources.profiles;
|
|
const initial = resource.loading && !appState.profiles.length;
|
|
if (initial) renderLoading(refs.profilesContent, "Cargando perfiles…");
|
|
try {
|
|
const payload = await api("/perfiles");
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
const parsed = parseProfiles(payload);
|
|
appState.profiles = parsed.profiles;
|
|
if (parsed.activeProfileKnown) {
|
|
appState.activeProfileId = parsed.activeProfileId;
|
|
appState.activeProfileKnown = true;
|
|
}
|
|
if (parsed.activeProfileKnown && !findById(appState.profiles, appState.activeProfileId)) {
|
|
appState.activeProfileId = appState.profiles[0]?.id ?? null;
|
|
}
|
|
if (!findById(appState.profiles, appState.selectedProfileId)) {
|
|
appState.selectedProfileId = appState.profiles[0]?.id ?? null;
|
|
}
|
|
resource.loading = false;
|
|
resource.error = null;
|
|
renderActiveProfileControl();
|
|
renderProfiles();
|
|
renderProfileTabs();
|
|
renderSchedules();
|
|
return true;
|
|
} catch (error) {
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
return false;
|
|
}
|
|
resource.loading = false;
|
|
resource.error = error;
|
|
if (initial) {
|
|
renderResourceError(refs.profilesContent, "No se pudieron cargar los perfiles", error, () => void loadProfiles());
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadSchedules() {
|
|
if (!appState.authenticated || refs.dashboard.hidden) return false;
|
|
const requestToken = appState.csrfToken;
|
|
const resource = appState.resources.schedules;
|
|
const initial = resource.loading && !appState.schedules.length;
|
|
if (initial) renderLoading(refs.schedulesContent, "Cargando horarios…");
|
|
try {
|
|
const payload = await api("/horarios");
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
appState.schedules = parseSchedules(payload);
|
|
resource.loading = false;
|
|
resource.error = null;
|
|
renderSchedules();
|
|
return true;
|
|
} catch (error) {
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
return false;
|
|
}
|
|
resource.loading = false;
|
|
resource.error = error;
|
|
if (initial) {
|
|
renderResourceError(refs.schedulesContent, "No se pudieron cargar los horarios", error, () => void loadSchedules());
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadPatterns() {
|
|
if (!appState.authenticated || refs.dashboard.hidden) return false;
|
|
const requestToken = appState.csrfToken;
|
|
const resource = appState.resources.patterns;
|
|
const initial = resource.loading && !appState.patterns.length;
|
|
if (initial) renderLoading(refs.patternsContent, "Cargando patrones…");
|
|
try {
|
|
const payload = await api("/patrones");
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
appState.patterns = parsePatterns(payload);
|
|
resource.loading = false;
|
|
resource.error = null;
|
|
renderPatterns();
|
|
renderStatus();
|
|
return true;
|
|
} catch (error) {
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
return false;
|
|
}
|
|
resource.loading = false;
|
|
resource.error = error;
|
|
if (initial) {
|
|
renderResourceError(refs.patternsContent, "No se pudieron cargar los patrones", error, () => void loadPatterns());
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadHolidays() {
|
|
if (!appState.authenticated || refs.dashboard.hidden) return false;
|
|
const requestToken = appState.csrfToken;
|
|
const resource = appState.resources.holidays;
|
|
const initial = resource.loading && !appState.holidays.length;
|
|
if (initial) renderLoading(refs.holidaysContent, "Cargando feriados…");
|
|
try {
|
|
const payload = await api("/feriados");
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
appState.holidays = parseHolidays(payload);
|
|
resource.loading = false;
|
|
resource.error = null;
|
|
renderHolidays();
|
|
return true;
|
|
} catch (error) {
|
|
if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false;
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
return false;
|
|
}
|
|
resource.loading = false;
|
|
resource.error = error;
|
|
if (initial) {
|
|
renderResourceError(refs.holidaysContent, "No se pudieron cargar los feriados", error, () => void loadHolidays());
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadAllData() {
|
|
const results = [];
|
|
for (const loader of [loadProfiles, loadSchedules, loadPatterns, loadHolidays]) {
|
|
if (!appState.authenticated || refs.dashboard.hidden) return false;
|
|
results.push(await loader());
|
|
}
|
|
return results.every(Boolean);
|
|
}
|
|
|
|
async function refreshAll() {
|
|
setButtonBusy(refs.refreshButton, true, "Actualizando…");
|
|
const resourcesOk = await loadAllData();
|
|
await pollStatus(false);
|
|
setButtonBusy(refs.refreshButton, false);
|
|
showToast(resourcesOk ? "Datos actualizados." : "Los datos se actualizaron parcialmente; revisa los avisos.", resourcesOk ? "success" : "error");
|
|
}
|
|
|
|
function updateActiveProfileSubmit() {
|
|
const selected = findById(appState.profiles, refs.activeProfileSelect.value);
|
|
const unchanged = appState.activeProfileKnown && sameId(selected?.id, appState.activeProfileId);
|
|
refs.activeProfileSubmit.disabled = !selected || unchanged || !appState.csrfToken;
|
|
}
|
|
|
|
function renderActiveProfileControl() {
|
|
const hasProfiles = appState.profiles.length > 0;
|
|
const select = refs.activeProfileSelect;
|
|
select.replaceChildren();
|
|
|
|
if (!hasProfiles && appState.resources.profiles.loading) {
|
|
select.append(createElement("option", "", "Cargando perfiles…"));
|
|
select.value = "";
|
|
} else if (!hasProfiles) {
|
|
const option = createElement("option", "", "No hay perfiles disponibles");
|
|
option.value = "";
|
|
select.append(option);
|
|
select.value = "";
|
|
} else {
|
|
if (!appState.activeProfileKnown) {
|
|
const placeholder = createElement("option", "", "Selecciona un perfil");
|
|
placeholder.value = "";
|
|
select.append(placeholder);
|
|
}
|
|
for (const profile of appState.profiles) {
|
|
const option = createElement("option", "", profile.name);
|
|
option.value = String(profile.id);
|
|
select.append(option);
|
|
}
|
|
select.value = appState.activeProfileKnown && findById(appState.profiles, appState.activeProfileId)
|
|
? String(appState.activeProfileId)
|
|
: "";
|
|
}
|
|
|
|
select.disabled = !hasProfiles;
|
|
updateActiveProfileSubmit();
|
|
}
|
|
|
|
function renderProfiles() {
|
|
refs.addProfileButton.disabled = appState.profiles.length >= MAX_PROFILES;
|
|
if (!appState.profiles.length) {
|
|
if (appState.resources.profiles.loading) {
|
|
renderLoading(refs.profilesContent, "Cargando perfiles…");
|
|
} else {
|
|
renderEmpty(
|
|
refs.profilesContent,
|
|
"No hay perfiles",
|
|
"Crea el primer perfil para empezar a organizar los horarios.",
|
|
"Añadir perfil",
|
|
() => openProfileDialog()
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const grid = createElement("div", "profile-grid");
|
|
for (const profile of appState.profiles) {
|
|
const isActive = appState.activeProfileKnown && sameId(profile.id, appState.activeProfileId);
|
|
const card = createElement("article", `profile-card${isActive ? " profile-card--active" : ""}`);
|
|
const heading = createElement("div", "profile-card__title");
|
|
heading.append(createElement("h3", "", profile.name));
|
|
if (isActive) {
|
|
const badge = createElement("span", "state-pill state-pill--active", "Activo");
|
|
heading.append(badge);
|
|
}
|
|
const actions = createElement("div", "profile-card__actions");
|
|
const editButton = createElement("button", "button button--secondary button--small", "Renombrar");
|
|
editButton.type = "button";
|
|
editButton.addEventListener("click", () => openProfileDialog(profile));
|
|
actions.append(editButton);
|
|
|
|
const deleteButton = createElement("button", "button button--danger button--small", "Eliminar");
|
|
deleteButton.type = "button";
|
|
deleteButton.disabled = appState.profiles.length <= 1;
|
|
deleteButton.addEventListener("click", () => confirmProfileDeletion(profile));
|
|
actions.append(deleteButton);
|
|
card.append(heading, actions);
|
|
grid.append(card);
|
|
}
|
|
refs.profilesContent.setAttribute("aria-busy", "false");
|
|
refs.profilesContent.replaceChildren(grid);
|
|
}
|
|
|
|
function renderProfileTabs() {
|
|
refs.profileTabs.replaceChildren();
|
|
if (!appState.profiles.length) {
|
|
const empty = createElement("span", "field-help", "Crea un perfil para ver sus horarios.");
|
|
refs.profileTabs.append(empty);
|
|
return;
|
|
}
|
|
for (const profile of appState.profiles) {
|
|
const button = createElement("button", "", profile.name);
|
|
button.type = "button";
|
|
button.setAttribute("role", "tab");
|
|
const selected = sameId(profile.id, appState.selectedProfileId);
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
button.tabIndex = selected ? 0 : -1;
|
|
button.addEventListener("click", () => selectProfile(profile.id));
|
|
button.addEventListener("keydown", (event) => handleProfileTabKeydown(event, profile.id));
|
|
refs.profileTabs.append(button);
|
|
}
|
|
}
|
|
|
|
function handleProfileTabKeydown(event, currentId) {
|
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
event.preventDefault();
|
|
if (!appState.profiles.length) return;
|
|
let index = appState.profiles.findIndex((profile) => sameId(profile.id, currentId));
|
|
if (index < 0) index = 0;
|
|
if (event.key === "Home") index = 0;
|
|
else if (event.key === "End") index = appState.profiles.length - 1;
|
|
else if (event.key === "ArrowRight") index = (index + 1) % appState.profiles.length;
|
|
else index = (index - 1 + appState.profiles.length) % appState.profiles.length;
|
|
const nextId = appState.profiles[index].id;
|
|
selectProfile(nextId);
|
|
const buttons = $$('[role="tab"]', refs.profileTabs);
|
|
if (buttons[index]) buttons[index].focus();
|
|
}
|
|
|
|
function selectProfile(id) {
|
|
appState.selectedProfileId = id;
|
|
renderProfileTabs();
|
|
renderSchedules();
|
|
}
|
|
|
|
function formatDays(mask) {
|
|
const selected = DAY_NAMES.filter((day) => (mask & (1 << day.index)) !== 0);
|
|
return selected.length ? selected.map((day) => day.short).join(" · ") : "Ningún día";
|
|
}
|
|
|
|
function dayBadges(mask) {
|
|
const list = createElement("div", "day-list");
|
|
for (const day of DAY_NAMES) {
|
|
const badge = createElement("span", "day-badge", day.short);
|
|
badge.title = day.name;
|
|
if ((mask & (1 << day.index)) === 0) badge.setAttribute("aria-label", `${day.name}: no seleccionado`);
|
|
else badge.setAttribute("aria-label", `${day.name}: seleccionado`);
|
|
list.append(badge);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function renderSchedules() {
|
|
refs.scheduleCount.textContent = `${appState.schedules.length} / ${MAX_SCHEDULES} horarios`;
|
|
refs.addScheduleButton.disabled = (
|
|
appState.schedules.length >= MAX_SCHEDULES ||
|
|
!appState.profiles.length ||
|
|
!appState.patterns.length ||
|
|
appState.resources.schedules.loading
|
|
);
|
|
renderProfileTabs();
|
|
|
|
if (appState.resources.schedules.loading && !appState.schedules.length) {
|
|
renderLoading(refs.schedulesContent, "Cargando horarios…");
|
|
return;
|
|
}
|
|
if (appState.resources.schedules.error && !appState.schedules.length) {
|
|
renderResourceError(refs.schedulesContent, "No se pudieron cargar los horarios", appState.resources.schedules.error, () => void loadSchedules());
|
|
return;
|
|
}
|
|
if (!appState.profiles.length) {
|
|
renderEmpty(refs.schedulesContent, "Primero crea un perfil", "Los horarios se organizan dentro de un perfil.");
|
|
return;
|
|
}
|
|
|
|
if (!findById(appState.profiles, appState.selectedProfileId)) {
|
|
appState.selectedProfileId = appState.profiles[0]?.id ?? null;
|
|
renderProfileTabs();
|
|
}
|
|
const visibleSchedules = appState.schedules
|
|
.filter((schedule) => sameId(schedule.profileId, appState.selectedProfileId))
|
|
.sort((left, right) => left.time.localeCompare(right.time));
|
|
if (!visibleSchedules.length) {
|
|
const profile = findById(appState.profiles, appState.selectedProfileId);
|
|
renderEmpty(
|
|
refs.schedulesContent,
|
|
"No hay horarios en este perfil",
|
|
`Añade una hora para ${profile ? profile.name : "este perfil"} o elige otro perfil.`,
|
|
"Añadir horario",
|
|
() => openScheduleDialog()
|
|
);
|
|
return;
|
|
}
|
|
|
|
const wrapper = createElement("div", "table-wrap");
|
|
const table = createElement("table", "data-table");
|
|
const caption = createElement("caption", "visually-hidden", "Horarios del perfil seleccionado");
|
|
const head = createElement("thead");
|
|
const headRow = createElement("tr");
|
|
for (const label of ["Hora", "Patrón", "Días", "Acciones"]) {
|
|
const th = createElement("th", "", label);
|
|
th.scope = "col";
|
|
headRow.append(th);
|
|
}
|
|
head.append(headRow);
|
|
const body = createElement("tbody");
|
|
|
|
for (const schedule of visibleSchedules) {
|
|
const row = createElement("tr");
|
|
const timeCell = createElement("td");
|
|
timeCell.append(createElement("span", "cell-title", schedule.time));
|
|
row.append(timeCell);
|
|
|
|
const pattern = findById(appState.patterns, schedule.patternId);
|
|
const patternCell = createElement("td");
|
|
patternCell.append(createElement("span", "cell-title", pattern ? pattern.legacyCode : `Patrón ${schedule.patternId}`));
|
|
patternCell.append(createElement("span", "cell-subtitle", pattern && !pattern.enabled ? "Deshabilitado" : "GPIO22 · principal"));
|
|
row.append(patternCell);
|
|
|
|
const daysCell = createElement("td");
|
|
daysCell.append(dayBadges(schedule.days));
|
|
row.append(daysCell);
|
|
|
|
const actionCell = createElement("td");
|
|
const actions = createElement("div", "cell-actions");
|
|
const editButton = createElement("button", "button button--secondary button--small", "Editar");
|
|
editButton.type = "button";
|
|
editButton.addEventListener("click", () => openScheduleDialog(schedule));
|
|
const deleteButton = createElement("button", "button button--danger button--small", "Eliminar");
|
|
deleteButton.type = "button";
|
|
deleteButton.addEventListener("click", () => confirmScheduleDeletion(schedule));
|
|
actions.append(editButton, deleteButton);
|
|
actionCell.append(actions);
|
|
row.append(actionCell);
|
|
body.append(row);
|
|
}
|
|
table.append(caption, head, body);
|
|
wrapper.append(table);
|
|
refs.schedulesContent.setAttribute("aria-busy", "false");
|
|
refs.schedulesContent.replaceChildren(wrapper);
|
|
}
|
|
|
|
function renderPatterns() {
|
|
refs.addPatternButton.disabled = appState.patterns.length >= MAX_PATTERNS;
|
|
if (appState.resources.patterns.loading && !appState.patterns.length) {
|
|
renderLoading(refs.patternsContent, "Cargando patrones…");
|
|
return;
|
|
}
|
|
if (appState.resources.patterns.error && !appState.patterns.length) {
|
|
renderResourceError(refs.patternsContent, "No se pudieron cargar los patrones", appState.resources.patterns.error, () => void loadPatterns());
|
|
return;
|
|
}
|
|
if (!appState.patterns.length) {
|
|
renderEmpty(
|
|
refs.patternsContent,
|
|
"No hay patrones",
|
|
"Crea un ciclo de seis fases para asociarlo a los horarios.",
|
|
"Añadir patrón",
|
|
() => openPatternDialog()
|
|
);
|
|
return;
|
|
}
|
|
|
|
const grid = createElement("div", "pattern-card-grid");
|
|
for (const pattern of appState.patterns) {
|
|
const card = createElement("article", "pattern-card");
|
|
const header = createElement("div", "pattern-card__header");
|
|
const title = createElement("div", "pattern-card__title");
|
|
title.append(createElement("span", "pattern-card__code", pattern.legacyCode));
|
|
const headingText = createElement("div");
|
|
headingText.append(createElement("h3", "", pattern.enabled ? "Patrón habilitado" : "Patrón deshabilitado"));
|
|
headingText.append(createElement("p", "cell-subtitle", "Destino: GPIO22 · principal"));
|
|
title.append(headingText);
|
|
header.append(title);
|
|
header.append(createElement("span", `state-pill ${pattern.enabled ? "state-pill--valid" : "state-pill--muted"}`, pattern.enabled ? "Activo" : "Inactivo"));
|
|
card.append(header);
|
|
|
|
const phases = createElement("div", "pattern-phases");
|
|
pattern.phaseSeconds.forEach((seconds, index) => {
|
|
const phase = createElement("div", "pattern-phase");
|
|
phase.append(createElement("strong", "", `${seconds} s`));
|
|
phase.append(createElement("span", "", PHASE_NAMES[index]));
|
|
phases.append(phase);
|
|
});
|
|
card.append(phases);
|
|
|
|
const total = pattern.phaseSeconds.reduce((sum, seconds) => sum + seconds, 0);
|
|
const footer = createElement("div", "pattern-card__footer");
|
|
footer.append(createElement("p", "", `Duración total: ${total} segundos`));
|
|
const actions = createElement("div", "pattern-card__actions");
|
|
const testButton = createElement("button", "button button--secondary button--small", "Probar");
|
|
testButton.type = "button";
|
|
testButton.disabled = !pattern.enabled;
|
|
testButton.addEventListener("click", (event) => void testPattern(pattern, event.currentTarget));
|
|
const editButton = createElement("button", "button button--secondary button--small", "Editar");
|
|
editButton.type = "button";
|
|
editButton.addEventListener("click", () => openPatternDialog(pattern));
|
|
actions.append(testButton, editButton);
|
|
footer.append(actions);
|
|
card.append(footer);
|
|
grid.append(card);
|
|
}
|
|
refs.patternsContent.setAttribute("aria-busy", "false");
|
|
refs.patternsContent.replaceChildren(grid);
|
|
}
|
|
|
|
function renderHolidays() {
|
|
refs.holidaySubmit.disabled = appState.holidays.length >= MAX_HOLIDAYS;
|
|
if (appState.resources.holidays.loading && !appState.holidays.length) {
|
|
renderLoading(refs.holidaysContent, "Cargando feriados…");
|
|
return;
|
|
}
|
|
if (appState.resources.holidays.error && !appState.holidays.length) {
|
|
renderResourceError(refs.holidaysContent, "No se pudieron cargar los feriados", appState.resources.holidays.error, () => void loadHolidays());
|
|
return;
|
|
}
|
|
if (!appState.holidays.length) {
|
|
renderEmpty(refs.holidaysContent, "No hay feriados", "Añade arriba una fecha que deba silenciar el timbre todos los años.");
|
|
return;
|
|
}
|
|
|
|
const list = createElement("div", "holiday-list");
|
|
for (const holiday of appState.holidays) {
|
|
const item = createElement("div", "holiday-item");
|
|
const date = createElement("div", "holiday-date");
|
|
date.append(createElement("strong", "", String(holiday.day).padStart(2, "0")));
|
|
date.append(createElement("span", "", MONTH_NAMES[holiday.month - 1] || `Mes ${holiday.month}`));
|
|
item.append(date);
|
|
const deleteButton = createElement("button", "button button--danger button--small", "Eliminar");
|
|
deleteButton.type = "button";
|
|
deleteButton.addEventListener("click", () => confirmHolidayDeletion(holiday));
|
|
item.append(deleteButton);
|
|
list.append(item);
|
|
}
|
|
refs.holidaysContent.setAttribute("aria-busy", "false");
|
|
refs.holidaysContent.replaceChildren(list);
|
|
}
|
|
|
|
function openDialog(dialog) {
|
|
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
else dialog.setAttribute("open", "");
|
|
}
|
|
|
|
function closeDialog(dialog) {
|
|
if (typeof dialog.close === "function") dialog.close();
|
|
else dialog.removeAttribute("open");
|
|
}
|
|
|
|
function openProfileDialog(profile = null) {
|
|
refs.profileForm.reset();
|
|
setFormError(refs.profileFormError);
|
|
refs.profileId.value = profile ? String(profile.id) : "";
|
|
refs.profileName.value = profile ? profile.name : "";
|
|
refs.profileDialogTitle.textContent = profile ? "Renombrar perfil" : "Añadir perfil";
|
|
openDialog(refs.profileDialog);
|
|
window.setTimeout(() => refs.profileName.focus(), 0);
|
|
}
|
|
|
|
function selectOptions(select, items, selectedValue) {
|
|
select.replaceChildren();
|
|
for (const item of items) {
|
|
const option = createElement("option", "", item.label);
|
|
option.value = String(item.value);
|
|
select.append(option);
|
|
}
|
|
if (selectedValue !== undefined && selectedValue !== null) select.value = String(selectedValue);
|
|
}
|
|
|
|
function buildProfileOptions() {
|
|
return appState.profiles.map((profile) => ({ value: profile.id, label: profile.name }));
|
|
}
|
|
|
|
function buildPatternOptions() {
|
|
return appState.patterns.map((pattern) => ({
|
|
value: pattern.id,
|
|
label: `${pattern.legacyCode} · ${pattern.enabled ? "habilitado" : "deshabilitado"}`
|
|
}));
|
|
}
|
|
|
|
function openScheduleDialog(schedule = null) {
|
|
if (appState.schedules.length >= MAX_SCHEDULES && !schedule) {
|
|
showToast(`El límite es de ${MAX_SCHEDULES} horarios. Elimina uno antes de añadir otro.`, "error");
|
|
return;
|
|
}
|
|
if (!appState.profiles.length) {
|
|
showToast("Crea un perfil antes de añadir un horario.", "error");
|
|
return;
|
|
}
|
|
if (!appState.patterns.length) {
|
|
showToast("Crea un patrón antes de añadir un horario.", "error");
|
|
return;
|
|
}
|
|
refs.scheduleForm.reset();
|
|
setFormError(refs.scheduleFormError);
|
|
refs.scheduleId.value = schedule ? String(schedule.id) : "";
|
|
refs.scheduleTime.value = schedule ? schedule.time : "";
|
|
selectOptions(refs.scheduleProfile, buildProfileOptions(), schedule ? schedule.profileId : appState.selectedProfileId);
|
|
selectOptions(refs.schedulePattern, buildPatternOptions(), schedule ? schedule.patternId : appState.patterns[0]?.id);
|
|
setScheduleDayMask(schedule ? schedule.days : 0);
|
|
refs.scheduleDialogTitle.textContent = schedule ? "Editar horario" : "Añadir horario";
|
|
openDialog(refs.scheduleDialog);
|
|
window.setTimeout(() => refs.scheduleTime.focus(), 0);
|
|
}
|
|
|
|
function setScheduleDayMask(mask) {
|
|
for (const input of $$('input[name="scheduleDay"]', refs.scheduleForm)) {
|
|
const day = Number(input.value);
|
|
input.checked = (mask & (1 << day)) !== 0;
|
|
}
|
|
}
|
|
|
|
function readScheduleDayMask() {
|
|
return $$('input[name="scheduleDay"]', refs.scheduleForm).reduce((mask, input) => {
|
|
return input.checked ? mask | (1 << Number(input.value)) : mask;
|
|
}, 0);
|
|
}
|
|
|
|
function openPatternDialog(pattern = null) {
|
|
refs.patternForm.reset();
|
|
setFormError(refs.patternFormError);
|
|
refs.patternId.value = pattern ? String(pattern.id) : "";
|
|
refs.patternCode.value = pattern ? pattern.legacyCode : "";
|
|
refs.patternEnabled.checked = pattern ? pattern.enabled : true;
|
|
for (let index = 0; index < 6; index += 1) {
|
|
const input = $(`#pattern-phase-${index}`, refs.patternForm);
|
|
input.value = pattern ? String(pattern.phaseSeconds[index]) : "0";
|
|
}
|
|
refs.patternDialogTitle.textContent = pattern ? "Editar patrón" : "Añadir patrón";
|
|
openDialog(refs.patternDialog);
|
|
window.setTimeout(() => refs.patternCode.focus(), 0);
|
|
}
|
|
|
|
function openConfirm(title, message, actionLabel, handler, danger = true) {
|
|
confirmHandler = handler;
|
|
refs.confirmTitle.textContent = title;
|
|
refs.confirmMessage.textContent = message;
|
|
refs.confirmActionButton.textContent = actionLabel;
|
|
refs.confirmActionButton.className = `button ${danger ? "button--danger" : "button--primary"}`;
|
|
openDialog(refs.confirmDialog);
|
|
window.setTimeout(() => refs.confirmActionButton.focus(), 0);
|
|
}
|
|
|
|
function confirmProfileDeletion(profile) {
|
|
if (appState.profiles.length <= 1) {
|
|
showToast("Debe existir al menos un perfil.", "error");
|
|
return;
|
|
}
|
|
openConfirm(
|
|
"Eliminar perfil",
|
|
`Se eliminará “${profile.name}” y sus horarios. Esta acción no se puede deshacer.`,
|
|
"Eliminar perfil",
|
|
() => deleteProfile(profile)
|
|
);
|
|
}
|
|
|
|
function confirmScheduleDeletion(schedule) {
|
|
openConfirm(
|
|
"Eliminar horario",
|
|
`Se eliminará el horario de las ${schedule.time}.`,
|
|
"Eliminar horario",
|
|
() => deleteSchedule(schedule)
|
|
);
|
|
}
|
|
|
|
function confirmHolidayDeletion(holiday) {
|
|
openConfirm(
|
|
"Eliminar feriado",
|
|
`Se eliminará el ${holiday.day} de ${MONTH_NAMES[holiday.month - 1] || "este mes"}.`,
|
|
"Eliminar feriado",
|
|
() => deleteHoliday(holiday)
|
|
);
|
|
}
|
|
|
|
async function handleMutation(button, operation, successMessage, busyText = "Guardando…") {
|
|
setButtonBusy(button, true, busyText);
|
|
try {
|
|
await operation();
|
|
if (successMessage) showToast(successMessage);
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
} else {
|
|
showToast(error instanceof Error ? error.message : "No se pudo completar la operación.", "error");
|
|
}
|
|
return false;
|
|
} finally {
|
|
setButtonBusy(button, false);
|
|
}
|
|
}
|
|
|
|
async function handleProfileSubmit(event) {
|
|
event.preventDefault();
|
|
setFormError(refs.profileFormError);
|
|
if (!refs.profileForm.reportValidity()) return;
|
|
const id = refs.profileId.value;
|
|
const existing = findById(appState.profiles, id);
|
|
const name = refs.profileName.value.trim();
|
|
if (!name) {
|
|
setFormError(refs.profileFormError, "Escribe un nombre para el perfil.");
|
|
return;
|
|
}
|
|
const duplicate = appState.profiles.some((profile) => (
|
|
profile.name.trim().toLocaleLowerCase("es") === name.toLocaleLowerCase("es") && !sameId(profile.id, id)
|
|
));
|
|
if (duplicate) {
|
|
setFormError(refs.profileFormError, "Ya existe un perfil con ese nombre.");
|
|
return;
|
|
}
|
|
if (!id && appState.profiles.length >= MAX_PROFILES) {
|
|
setFormError(refs.profileFormError, `No se pueden crear más de ${MAX_PROFILES} perfiles.`);
|
|
return;
|
|
}
|
|
|
|
setButtonBusy(refs.profileSubmit, true);
|
|
try {
|
|
await api(id ? "/perfiles/actualizar" : "/perfiles", {
|
|
method: "POST",
|
|
body: id ? { id: existing.id, name } : { name }
|
|
});
|
|
closeDialog(refs.profileDialog);
|
|
showToast(id ? "Perfil actualizado." : "Perfil creado.");
|
|
await loadProfiles();
|
|
} catch (error) {
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
} else {
|
|
setFormError(refs.profileFormError, error instanceof Error ? error.message : "No se pudo guardar el perfil.");
|
|
}
|
|
} finally {
|
|
setButtonBusy(refs.profileSubmit, false);
|
|
}
|
|
}
|
|
|
|
async function deleteProfile(profile) {
|
|
const ok = await handleMutation(
|
|
null,
|
|
() => api("/perfiles/eliminar", { method: "POST", body: { id: profile.id } }),
|
|
"Perfil eliminado.",
|
|
"Eliminando…"
|
|
);
|
|
if (ok) await loadAllData();
|
|
}
|
|
|
|
async function handleActiveProfileSubmit(event) {
|
|
event.preventDefault();
|
|
const profile = findById(appState.profiles, refs.activeProfileSelect.value);
|
|
if (!profile) {
|
|
showToast("Selecciona un perfil válido.", "error");
|
|
return;
|
|
}
|
|
const ok = await handleMutation(
|
|
refs.activeProfileSubmit,
|
|
() => api("/config/perfil-activo", { method: "POST", body: { id: profile.id } }),
|
|
"Perfil activo actualizado.",
|
|
"Guardando…"
|
|
);
|
|
if (ok) {
|
|
appState.activeProfileId = profile.id;
|
|
appState.activeProfileKnown = true;
|
|
renderActiveProfileControl();
|
|
renderProfiles();
|
|
}
|
|
}
|
|
|
|
async function handleScheduleSubmit(event) {
|
|
event.preventDefault();
|
|
setFormError(refs.scheduleFormError);
|
|
if (!refs.scheduleForm.reportValidity()) return;
|
|
const id = refs.scheduleId.value;
|
|
const days = readScheduleDayMask();
|
|
if (days === 0) {
|
|
setFormError(refs.scheduleFormError, "Selecciona al menos un día de la semana.");
|
|
return;
|
|
}
|
|
if (!id && appState.schedules.length >= MAX_SCHEDULES) {
|
|
setFormError(refs.scheduleFormError, `No se pueden guardar más de ${MAX_SCHEDULES} horarios.`);
|
|
return;
|
|
}
|
|
const profile = findById(appState.profiles, refs.scheduleProfile.value);
|
|
const pattern = findById(appState.patterns, refs.schedulePattern.value);
|
|
if (!profile || !pattern) {
|
|
setFormError(refs.scheduleFormError, "El perfil o el patrón seleccionados ya no están disponibles.");
|
|
return;
|
|
}
|
|
const body = {
|
|
profileId: profile.id,
|
|
time: refs.scheduleTime.value,
|
|
patternId: pattern.id,
|
|
days
|
|
};
|
|
if (id) body.id = findById(appState.schedules, id)?.id ?? id;
|
|
|
|
setButtonBusy(refs.scheduleSubmit, true);
|
|
try {
|
|
await api(id ? "/horarios/actualizar" : "/horarios", { method: "POST", body });
|
|
closeDialog(refs.scheduleDialog);
|
|
showToast(id ? "Horario actualizado." : "Horario creado.");
|
|
await loadSchedules();
|
|
} catch (error) {
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
} else {
|
|
setFormError(refs.scheduleFormError, error instanceof Error ? error.message : "No se pudo guardar el horario.");
|
|
}
|
|
} finally {
|
|
setButtonBusy(refs.scheduleSubmit, false);
|
|
}
|
|
}
|
|
|
|
async function deleteSchedule(schedule) {
|
|
const ok = await handleMutation(
|
|
null,
|
|
() => api("/horarios/eliminar", { method: "POST", body: { id: schedule.id } }),
|
|
"Horario eliminado.",
|
|
"Eliminando…"
|
|
);
|
|
if (ok) await loadSchedules();
|
|
}
|
|
|
|
async function handlePatternSubmit(event) {
|
|
event.preventDefault();
|
|
setFormError(refs.patternFormError);
|
|
if (!refs.patternForm.reportValidity()) return;
|
|
const id = refs.patternId.value;
|
|
const legacyCode = refs.patternCode.value.trim().toUpperCase();
|
|
if (!["A", "B", "C"].includes(legacyCode)) {
|
|
setFormError(refs.patternFormError, "El código debe ser A, B o C.");
|
|
return;
|
|
}
|
|
const duplicate = appState.patterns.some((pattern) => (
|
|
pattern.legacyCode.toUpperCase() === legacyCode && !sameId(pattern.id, id)
|
|
));
|
|
if (duplicate) {
|
|
setFormError(refs.patternFormError, "Ya existe un patrón con ese código.");
|
|
return;
|
|
}
|
|
const phaseSeconds = Array.from({ length: 6 }, (_, index) => {
|
|
const input = $(`#pattern-phase-${index}`, refs.patternForm);
|
|
return Math.max(0, Math.trunc(asFiniteNumber(input.value) ?? 0));
|
|
});
|
|
if (phaseSeconds.some((seconds) => seconds > MAX_PHASE_SECONDS)) {
|
|
setFormError(refs.patternFormError, `Cada fase debe durar como máximo ${MAX_PHASE_SECONDS} segundos.`);
|
|
return;
|
|
}
|
|
const totalSeconds = phaseSeconds.reduce((sum, seconds) => sum + seconds, 0);
|
|
if (totalSeconds > MAX_PATTERN_SECONDS) {
|
|
setFormError(refs.patternFormError, `La duración total no puede superar ${MAX_PATTERN_SECONDS} segundos.`);
|
|
return;
|
|
}
|
|
if (!id && appState.patterns.length >= MAX_PATTERNS) {
|
|
setFormError(refs.patternFormError, `No se pueden crear más de ${MAX_PATTERNS} patrones.`);
|
|
return;
|
|
}
|
|
const existing = findById(appState.patterns, id);
|
|
const targetReference = existing || appState.patterns.find((pattern) => sameId(pattern.target, 22));
|
|
const targetUsesPinNumber = targetReference && (
|
|
targetReference.target === 22 || asString(targetReference.target).trim() === "22"
|
|
);
|
|
const body = {
|
|
legacyCode,
|
|
target: targetUsesPinNumber ? 22 : "GPIO22",
|
|
phaseSeconds,
|
|
enabled: refs.patternEnabled.checked
|
|
};
|
|
if (id) body.id = existing ? existing.id : id;
|
|
|
|
setButtonBusy(refs.patternSubmit, true);
|
|
try {
|
|
await api(id ? "/patrones/actualizar" : "/patrones", { method: "POST", body });
|
|
closeDialog(refs.patternDialog);
|
|
showToast(id ? "Patrón actualizado." : "Patrón creado.");
|
|
await loadPatterns();
|
|
await loadSchedules();
|
|
} catch (error) {
|
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
handleSessionExpired();
|
|
} else {
|
|
setFormError(refs.patternFormError, error instanceof Error ? error.message : "No se pudo guardar el patrón.");
|
|
}
|
|
} finally {
|
|
setButtonBusy(refs.patternSubmit, false);
|
|
}
|
|
}
|
|
|
|
async function testPattern(pattern, button) {
|
|
const ok = await handleMutation(
|
|
button,
|
|
() => api("/acciones/probar", { method: "POST", body: { patternId: pattern.id } }),
|
|
`Prueba del patrón ${pattern.legacyCode} iniciada.`,
|
|
"Probando…"
|
|
);
|
|
if (ok) window.setTimeout(() => void pollStatus(false), 250);
|
|
}
|
|
|
|
async function handleStopAction() {
|
|
const ok = await handleMutation(
|
|
refs.stopButton,
|
|
() => api("/acciones/detener", { method: "POST" }),
|
|
"Secuencia detenida.",
|
|
"Deteniendo…"
|
|
);
|
|
if (ok) window.setTimeout(() => void pollStatus(false), 250);
|
|
}
|
|
|
|
function maximumDayForMonth(month) {
|
|
return [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] || 31;
|
|
}
|
|
|
|
function updateHolidayDayLimit() {
|
|
const month = Math.trunc(asFiniteNumber(refs.holidayMonth.value) || 1);
|
|
const maximum = maximumDayForMonth(month);
|
|
refs.holidayDay.max = String(maximum);
|
|
if (asFiniteNumber(refs.holidayDay.value) > maximum) refs.holidayDay.value = String(maximum);
|
|
}
|
|
|
|
async function handleHolidaySubmit(event) {
|
|
event.preventDefault();
|
|
setFormError(refs.holidayError);
|
|
if (!refs.holidayForm.reportValidity()) return;
|
|
const month = Math.trunc(asFiniteNumber(refs.holidayMonth.value) ?? 0);
|
|
const day = Math.trunc(asFiniteNumber(refs.holidayDay.value) ?? 0);
|
|
if (month < 1 || month > 12) {
|
|
setFormError(refs.holidayError, "Selecciona un mes válido.");
|
|
return;
|
|
}
|
|
if (day < 1 || day > maximumDayForMonth(month)) {
|
|
setFormError(refs.holidayError, "Selecciona un día válido para el mes.");
|
|
return;
|
|
}
|
|
if (appState.holidays.some((holiday) => holiday.month === month && holiday.day === day)) {
|
|
setFormError(refs.holidayError, "Ese feriado ya está registrado.");
|
|
return;
|
|
}
|
|
|
|
const ok = await handleMutation(
|
|
refs.holidaySubmit,
|
|
() => api("/feriados", { method: "POST", body: { month, day } }),
|
|
"Feriado añadido.",
|
|
"Añadiendo…"
|
|
);
|
|
if (ok) {
|
|
refs.holidayDay.value = "1";
|
|
await loadHolidays();
|
|
}
|
|
}
|
|
|
|
async function deleteHoliday(holiday) {
|
|
const ok = await handleMutation(
|
|
null,
|
|
() => api("/feriados/eliminar", {
|
|
method: "POST",
|
|
body: { month: holiday.month, day: holiday.day }
|
|
}),
|
|
"Feriado eliminado.",
|
|
"Eliminando…"
|
|
);
|
|
if (ok) await loadHolidays();
|
|
}
|
|
|
|
async function handleSyncTime() {
|
|
const ok = await handleMutation(
|
|
refs.syncTimeButton,
|
|
() => api("/hora", { method: "POST", body: { epoch: Math.floor(Date.now() / 1000) } }),
|
|
"Hora del dispositivo sincronizada.",
|
|
"Sincronizando…"
|
|
);
|
|
if (ok) {
|
|
appState.timeSyncAttempted = true;
|
|
await pollStatus(false);
|
|
}
|
|
}
|
|
|
|
function buildStaticControls() {
|
|
for (let month = 1; month <= 12; month += 1) {
|
|
const option = createElement("option", "", `${month} · ${MONTH_NAMES[month - 1]}`);
|
|
option.value = String(month);
|
|
refs.holidayMonth.append(option);
|
|
}
|
|
refs.holidayMonth.value = "1";
|
|
|
|
for (const day of DAY_NAMES) {
|
|
const label = createElement("label", "day-option");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.name = "scheduleDay";
|
|
input.value = String(day.index);
|
|
const text = createElement("span", "", day.short);
|
|
text.title = day.name;
|
|
label.append(input, text);
|
|
refs.scheduleDays.append(label);
|
|
}
|
|
|
|
for (let index = 0; index < 6; index += 1) {
|
|
const field = createElement("div", "phase-field");
|
|
const label = createElement("label", "", PHASE_NAMES[index]);
|
|
label.htmlFor = `pattern-phase-${index}`;
|
|
const input = document.createElement("input");
|
|
input.type = "number";
|
|
input.id = `pattern-phase-${index}`;
|
|
input.name = `phaseSeconds[${index}]`;
|
|
input.min = "0";
|
|
input.max = String(MAX_PHASE_SECONDS);
|
|
input.step = "1";
|
|
input.value = "0";
|
|
input.required = true;
|
|
input.inputMode = "numeric";
|
|
field.append(label, input);
|
|
refs.patternPhases.append(field);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
refs.bootstrapRetry.addEventListener("click", () => void bootstrap());
|
|
refs.claimForm.addEventListener("submit", (event) => void handleClaimSubmit(event));
|
|
refs.loginForm.addEventListener("submit", (event) => void handleLoginSubmit(event));
|
|
refs.logoutButton.addEventListener("click", () => void handleLogout());
|
|
refs.refreshButton.addEventListener("click", () => void refreshAll());
|
|
refs.syncTimeButton.addEventListener("click", () => void handleSyncTime());
|
|
refs.stopButton.addEventListener("click", () => openConfirm(
|
|
"Detener secuencia",
|
|
"La salida GPIO22 se pondrá en reposo de inmediato.",
|
|
"Detener ahora",
|
|
() => handleStopAction(),
|
|
true
|
|
));
|
|
refs.addProfileButton.addEventListener("click", () => openProfileDialog());
|
|
refs.activeProfileForm.addEventListener("submit", (event) => void handleActiveProfileSubmit(event));
|
|
refs.activeProfileSelect.addEventListener("change", updateActiveProfileSubmit);
|
|
refs.addScheduleButton.addEventListener("click", () => openScheduleDialog());
|
|
refs.addPatternButton.addEventListener("click", () => openPatternDialog());
|
|
refs.profileForm.addEventListener("submit", (event) => void handleProfileSubmit(event));
|
|
refs.scheduleForm.addEventListener("submit", (event) => void handleScheduleSubmit(event));
|
|
refs.patternForm.addEventListener("submit", (event) => void handlePatternSubmit(event));
|
|
refs.holidayForm.addEventListener("submit", (event) => void handleHolidaySubmit(event));
|
|
refs.holidayMonth.addEventListener("change", updateHolidayDayLimit);
|
|
|
|
for (const closeButton of $$("[data-close-dialog]")) {
|
|
closeButton.addEventListener("click", () => closeDialog(closeButton.closest("dialog")));
|
|
}
|
|
refs.confirmDialog.addEventListener("close", () => { confirmHandler = null; });
|
|
refs.confirmActionButton.addEventListener("click", () => {
|
|
const handler = confirmHandler;
|
|
confirmHandler = null;
|
|
closeDialog(refs.confirmDialog);
|
|
if (handler) void handler();
|
|
});
|
|
}
|
|
|
|
buildStaticControls();
|
|
bindEvents();
|
|
void bootstrap();
|
|
})();
|