34 Commits

Author SHA1 Message Date
Naiel
90df81d308 Update asset path for GitHub Release upload 2026-03-03 10:24:12 +01:00
Naiel
53941da35c Update Windows agent release workflow for PyInstaller 2026-03-03 10:21:47 +01:00
Naiel
105c911c59 feat: añadir soporte para configuración JSON en el agente de Windows 2026-03-02 12:47:02 +00:00
Naiel
cb12894455 feat: añadir agente de Windows y soporte para control remoto de ordenadores 2026-03-02 12:39:13 +00:00
Naiel
9d808ed63e feat: update print button functionality to use onclick event for improved usability 2026-03-02 08:08:59 +00:00
Naiel
d0593d3d46 chore: clean up empty code change sections in the changes log 2026-03-02 08:06:06 +00:00
Naiel
8b7d0258ae Refactor code structure for improved readability and maintainability 2026-03-01 23:37:37 +00:00
Naiel
9a760a1d24 fix: Ajustar el tamaño mínimo de las columnas en la cuadrícula de estadísticas en la página de inicio 2026-02-25 14:27:33 +00:00
Naiel
e1f780ea11 feat: Añadir estadísticas de ingresos, gastos y mensajes sin leer en la página de inicio 2026-02-25 14:25:12 +00:00
Naiel
7ad2e9c142 feat: Añadir funcionalidad de filtrado en la búsqueda de elementos y actualizar etiquetas en la página de pagos 2026-02-25 14:00:42 +00:00
Naiel
879554a7ab feat: Implementar limpieza automática de menús antiguos en el comedor y eliminar la tienda de apps 2026-02-25 13:31:05 +00:00
Naiel
0ef6e5a233 feat: Añadir sincronización en tiempo real para la gestión de aplicaciones 2026-02-25 13:20:43 +00:00
Naiel
d905e86bbf feat: Añadir funcionalidad de gestión de mensajes con soporte para adjuntos 2026-02-25 13:17:52 +00:00
Naiel
3764473b5b feat: Añadir funcionalidad de gestión de apps en la tienda de apps 2026-02-25 12:45:38 +00:00
Naiel
382e31158a feat: Añadir modo de revisión y mejorar la retroalimentación en el panel 2026-02-24 12:17:26 +00:00
Naiel
09a9a95df0 refactor: Actualizar referencias de contenedores en la gestión de comandas 2026-02-23 15:14:12 +00:00
Naiel
b04dbbf19d feat: Actualizar el título y el mensaje de seguridad en la interfaz principal 2026-02-23 15:12:01 +00:00
Naiel
7619444556 feat: Añadir funcionalidad para alternar la visibilidad del menú en la interfaz 2026-02-23 15:10:03 +00:00
Naiel
076aa45337 feat: Añadir funcionalidad para mostrar pictogramas en opciones del panel y mejorar estilos de presentación 2026-02-23 14:59:29 +00:00
Naiel
0b1419fae2 Añadir parámetro de visibilidad en mensajes de 'No hay personas registradas' en secciones de Monedero 2026-02-23 14:41:18 +00:00
Naiel
74afb2a499 Modificar parámetro de función 'edit' para mejorar la claridad en la gestión de secciones 2026-02-23 14:38:26 +00:00
Naiel
543d1c3202 feat: Add panel page with daily quiz and update functionality
- Introduced a new panel page that includes a daily quiz based on the menu and tasks for the day.
- Implemented functions to fetch and decrypt daily data, build quiz questions, and render the quiz interface.
- Added a button to refresh the application, clearing the cache and updating the service worker.
- Enhanced service worker to manage application version checks and handle CouchDB URL prefix.
- Created a version.json file to manage application versioning.
2026-02-23 14:37:08 +00:00
Naiel
75947d3468 Refactor manejo de URL para eliminar parámetros de búsqueda en la navegación y mejorar la visibilidad de personas en formularios 2026-02-23 12:38:28 +00:00
Naiel
9ab0472e2a Modificar parámetro de función 'edit' para mejorar la claridad y manejo de transacciones 2026-02-23 11:34:25 +00:00
naielv
aa993df2bf Añadir funcionalidad de selección de pictogramas en el formulario del comedor y refactorizar campos de entrada 2026-02-23 00:23:11 +01:00
Naiel
e0da65811e Modificar texto de "Total Gastos" a "Total Ganancias" y simplificar lógica de cálculo de ingresos y gastos 2026-02-12 14:37:27 +00:00
Naiel
eb6a956cdc Añadir manejo de eventos de base de datos y mejorar la carga de precios del café 2026-02-12 14:30:59 +00:00
Naiel
dc4ba25b20 Refactor code structure for improved readability and maintainability 2026-02-12 14:17:05 +00:00
Naiel
129188c022 Añadir configuración de precios del café y formulario de edición en la página de administración 2026-02-12 14:05:26 +00:00
Naiel
9d4ce881c6 fix 2026-02-10 12:54:12 +00:00
Naiel
4e1727adc3 update 2026-02-10 12:52:55 +00:00
naielv
db5b07bb44 update 2026-02-06 23:55:29 +01:00
naielv
61b8cb8af4 Reapply "Add Cajas module for cash register transaction management"
This reverts commit 8b29e3f425.
2026-02-06 23:26:51 +01:00
Naiel
2ee03aa204 Merge pull request #19 from EuskadiTech/revert-18-copilot/add-cajas-module
Revert "Add Cajas module for cash register transaction management"
2026-02-06 13:17:33 +01:00
40 changed files with 3693 additions and 319 deletions

View File

@@ -0,0 +1,46 @@
name: Build Windows Agent (Release)
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
build-windows-agent:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Build hidden EXE with PyInstaller
shell: bash
run: |
cd python_sdk ; pyinstaller --noconfirm --clean --onefile --noconsole --hidden-import=telesec_couchdb --name telesec-windows-agent windows_agent.py
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: telesec-windows-agent
path: python_sdk/dist/telesec-windows-agent.exe
- name: Upload asset to GitHub Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: python_sdk/dist/telesec-windows-agent.exe

11
.gitignore vendored
View File

@@ -2,4 +2,13 @@ dist/*
radata/*
node_modules/*
.DS_Store
._*
._*
# Python
__pycache__/*
*.pyc
*.pyo
*.pyd
*.egg-info/*
*.egg
.venv/*
venv/*

6
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"tobermory.es6-string-html",
"esbenp.prettier-vscode"
]
}

View File

@@ -1,2 +1,94 @@
# TeleSec
Nuevo programa de datos
## Python SDK (CouchDB directo)
Se añadió un SDK Python en `python_sdk/` para acceder directamente a CouchDB (sin replicación local), compatible con el formato de cifrado de `TS_encrypt`:
- Formato: `RSA{...}`
- Algoritmo: `CryptoJS.AES.encrypt(payload, secret)` (modo passphrase/OpenSSL)
### Instalación
```bash
pip install -r requirements.txt
```
### Uso rápido
```python
from python_sdk import TeleSecCouchDB
db = TeleSecCouchDB(
server_url="https://tu-couchdb",
dbname="telesec",
username="usuario",
password="clave",
secret="SECRET123",
)
# Guardar cifrado (como TS_encrypt)
db.put("personas", "abc123", {"nombre": "Ana"}, encrypt=True)
# Leer y descifrar
obj = db.get("personas", "abc123", decrypt=True)
# Listar una tabla
rows = db.list("personas", decrypt=True)
for row in rows:
print(row.id, row.data)
```
API principal:
- `TeleSecCouchDB.put(table, item_id, data, encrypt=True)`
- `TeleSecCouchDB.get(table, item_id, decrypt=True)`
- `TeleSecCouchDB.list(table, decrypt=True)`
- `TeleSecCouchDB.delete(table, item_id)`
- `ts_encrypt(value, secret)` / `ts_decrypt(value, secret)`
## Agente Windows (Gest-Aula > Ordenadores)
Se añadió soporte para control de ordenadores del aula:
- Tabla: `aulas_ordenadores`
- Campos reportados por agente: `Hostname`, `UsuarioActual`, `AppActualEjecutable`, `AppActualTitulo`, `LastSeenAt`
- Control remoto: `ShutdownBeforeDate` (programado desde web a `hora_servidor + 2 minutos`)
### Ejecutar agente en Windows
El agente usa un archivo de configuración en la carpeta personal del usuario:
- Ruta por defecto: `~/.telesec/windows_agent.json`
- Se crea automáticamente si no existe
```bash
python -m python_sdk.windows_agent --once
```
Ejemplo del JSON de configuración:
```json
{
"server": "https://tu-couchdb",
"db": "telesec",
"user": "usuario",
"password": "clave",
"secret": "SECRET123",
"machine_id": "",
"interval": 15
}
```
También puedes sobrescribir valores por CLI (`--server`, `--secret`, etc.).
Opciones útiles:
- `--once`: una sola iteración
- `--interval 15`: intervalo (segundos)
- `--dry-run`: no apaga realmente, solo simula
- `--config <ruta>`: ruta alternativa del archivo JSON
### Hora de servidor (sin depender del reloj local)
El frontend y el agente usan la hora del servidor (cabecera HTTP `Date` de CouchDB) para comparar `ShutdownBeforeDate`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
assets/static/cash_flow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

20
assets/static/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -428,4 +428,47 @@ fieldset legend {
}
pre {
font-size: 15px;
}
.picto {
min-height: 125px;
width: 100px;
border: 2.5px solid black;
border-radius: 5px;
text-align: center;
background: white;
margin-bottom: 20px;
margin-left: auto;
margin-right: auto;
}
.picto b {
padding-top: 40px;
display: inline-block;
}
.panel-option input {
display: none;
}
.panel-option:has(input:checked) {
background-color: #ccc;
outline: 5px solid blue;
}
.saveico {
border-color: green !important;
}
.delico {
border-color: red !important;
}
.opicon {
border-color: blue !important;
}
.saveico img, .delico img, .opicon img {
height: 52px;
vertical-align: middle;
}
.saveico, .delico, .opicon {
padding: 2.5px 7.5px;
background: transparent;
border-radius: 10px;
border: 4px solid;
}

BIN
assets/static/exchange.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
assets/static/exit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/static/find.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
assets/static/garbage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
assets/static/printer2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -2,6 +2,7 @@ import json
import os
import shutil
import sys
import time
def get_all_files(directory):
files = []
@@ -14,7 +15,7 @@ def get_all_files(directory):
return files
PREFETCH = ""
VERSIONCO = "2026-02"
VERSIONCO = "2026-02-23_" + time.strftime("%Y%m%d%H%M%S")
HANDLEPARSE = get_all_files("src")
TITLE = os.environ.get("TELESEC_TITLE", "TeleSec")
HOSTER = os.environ.get("TELESEC_HOSTER", "EuskadiTech")

3
python_sdk/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .telesec_couchdb import TeleSecCouchDB, ts_encrypt, ts_decrypt
__all__ = ["TeleSecCouchDB", "ts_encrypt", "ts_decrypt"]

View File

@@ -0,0 +1,293 @@
import base64
import email.utils
import hashlib
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import requests
from Crypto.Cipher import AES
class TeleSecCryptoError(Exception):
pass
class TeleSecCouchDBError(Exception):
pass
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
pad_len = block_size - (len(data) % block_size)
return data + bytes([pad_len]) * pad_len
def _pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes:
if not data or len(data) % block_size != 0:
raise TeleSecCryptoError("Invalid padded data length")
pad_len = data[-1]
if pad_len < 1 or pad_len > block_size:
raise TeleSecCryptoError("Invalid PKCS7 padding")
if data[-pad_len:] != bytes([pad_len]) * pad_len:
raise TeleSecCryptoError("Invalid PKCS7 padding bytes")
return data[:-pad_len]
def _evp_bytes_to_key(passphrase: bytes, salt: bytes, key_len: int, iv_len: int) -> tuple[bytes, bytes]:
d = b""
prev = b""
while len(d) < key_len + iv_len:
prev = hashlib.md5(prev + passphrase + salt).digest()
d += prev
return d[:key_len], d[key_len : key_len + iv_len]
def _json_dumps_like_js(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def ts_encrypt(input_value: Any, secret: str) -> str:
"""
Compatible with JS: CryptoJS.AES.encrypt(payload, secret).toString()
wrapped as RSA{<ciphertext>}.
"""
if secret is None or secret == "":
if isinstance(input_value, str):
return input_value
return _json_dumps_like_js(input_value)
payload = input_value
if not isinstance(input_value, str):
try:
payload = _json_dumps_like_js(input_value)
except Exception:
payload = str(input_value)
payload_bytes = payload.encode("utf-8")
salt = os.urandom(8)
key, iv = _evp_bytes_to_key(secret.encode("utf-8"), salt, 32, 16)
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
encrypted = cipher.encrypt(_pkcs7_pad(payload_bytes, 16))
openssl_blob = b"Salted__" + salt + encrypted
b64 = base64.b64encode(openssl_blob).decode("utf-8")
return f"RSA{{{b64}}}"
def ts_decrypt(input_value: Any, secret: str) -> Any:
"""
Compatible with JS TS_decrypt behavior:
- If not string: return as-is.
- If RSA{...}: decrypt AES(CryptoJS passphrase mode), parse JSON when possible.
- If plain string JSON: parse JSON.
- Else: return raw string.
"""
if not isinstance(input_value, str):
return input_value
is_wrapped = input_value.startswith("RSA{") and input_value.endswith("}")
if is_wrapped:
if not secret:
raise TeleSecCryptoError("Secret is required to decrypt RSA payload")
b64 = input_value[4:-1]
try:
raw = base64.b64decode(b64)
except Exception as exc:
raise TeleSecCryptoError("Invalid base64 payload") from exc
if len(raw) < 16 or not raw.startswith(b"Salted__"):
raise TeleSecCryptoError("Unsupported encrypted payload format")
salt = raw[8:16]
ciphertext = raw[16:]
key, iv = _evp_bytes_to_key(secret.encode("utf-8"), salt, 32, 16)
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
decrypted = cipher.decrypt(ciphertext)
decrypted = _pkcs7_unpad(decrypted, 16)
try:
text = decrypted.decode("utf-8")
except UnicodeDecodeError:
text = decrypted.decode("latin-1")
try:
return json.loads(text)
except Exception:
return text
try:
return json.loads(input_value)
except Exception:
return input_value
@dataclass
class TeleSecDoc:
id: str
data: Any
raw: Dict[str, Any]
class TeleSecCouchDB:
"""
Direct CouchDB client for TeleSec docs (_id = "<table>:<id>").
No local replication layer.
"""
def __init__(
self,
server_url: str,
dbname: str,
secret: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: int = 30,
session: Optional[requests.Session] = None,
) -> None:
self.server_url = server_url.rstrip("/")
self.dbname = dbname
self.secret = secret or ""
self.timeout = timeout
self.base_url = f"{self.server_url}/{quote(self.dbname, safe='')}"
self.session = session or requests.Session()
self.session.headers.update({"Accept": "application/json"})
if username is not None:
self.session.auth = (username, password or "")
def _iso_now(self) -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _doc_id(self, table: str, item_id: str) -> str:
return f"{table}:{item_id}"
def _request(self, method: str, path: str = "", **kwargs) -> requests.Response:
url = self.base_url if not path else f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
res = self.session.request(method=method, url=url, **kwargs)
return res
def get_server_datetime(self) -> datetime:
"""
Returns server datetime using HTTP Date header from CouchDB.
Avoids reliance on local machine clock.
"""
candidates = [
("HEAD", self.base_url),
("GET", self.base_url),
("HEAD", self.server_url),
("GET", self.server_url),
]
for method, url in candidates:
try:
res = self.session.request(method=method, url=url, timeout=self.timeout)
date_header = res.headers.get("Date")
if not date_header:
continue
dt = email.utils.parsedate_to_datetime(date_header)
if dt is None:
continue
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
continue
raise TeleSecCouchDBError("Unable to retrieve server time from CouchDB Date header")
def iso_from_server_plus_minutes(self, minutes: int = 0) -> str:
now = self.get_server_datetime()
target = now.timestamp() + (minutes * 60)
out = datetime.fromtimestamp(target, tz=timezone.utc)
return out.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def check_connection(self) -> Dict[str, Any]:
res = self._request("GET")
if res.status_code >= 400:
raise TeleSecCouchDBError(f"CouchDB connection failed: {res.status_code} {res.text}")
return res.json()
def get_raw(self, doc_id: str) -> Optional[Dict[str, Any]]:
res = self._request("GET", quote(doc_id, safe=""))
if res.status_code == 404:
return None
if res.status_code >= 400:
raise TeleSecCouchDBError(f"GET doc failed: {res.status_code} {res.text}")
return res.json()
def put_raw(self, doc: Dict[str, Any]) -> Dict[str, Any]:
if "_id" not in doc:
raise ValueError("Document must include _id")
res = self._request(
"PUT",
quote(doc["_id"], safe=""),
headers={"Content-Type": "application/json"},
data=_json_dumps_like_js(doc).encode("utf-8"),
)
if res.status_code >= 400:
raise TeleSecCouchDBError(f"PUT doc failed: {res.status_code} {res.text}")
return res.json()
def delete_raw(self, doc_id: str) -> bool:
doc = self.get_raw(doc_id)
if not doc:
return False
res = self._request("DELETE", f"{quote(doc_id, safe='')}?rev={quote(doc['_rev'], safe='')}")
if res.status_code >= 400:
raise TeleSecCouchDBError(f"DELETE doc failed: {res.status_code} {res.text}")
return True
def put(self, table: str, item_id: str, data: Any, encrypt: bool = True) -> Dict[str, Any]:
doc_id = self._doc_id(table, item_id)
if data is None:
self.delete_raw(doc_id)
return {"ok": True, "id": doc_id, "deleted": True}
existing = self.get_raw(doc_id)
doc: Dict[str, Any] = existing if existing else {"_id": doc_id}
to_store = data
is_encrypted_string = isinstance(data, str) and data.startswith("RSA{") and data.endswith("}")
if encrypt and self.secret and not is_encrypted_string:
to_store = ts_encrypt(data, self.secret)
doc["data"] = to_store
doc["table"] = table
doc["ts"] = self._iso_now()
return self.put_raw(doc)
def get(self, table: str, item_id: str, decrypt: bool = True) -> Optional[Any]:
doc_id = self._doc_id(table, item_id)
doc = self.get_raw(doc_id)
if not doc:
return None
value = doc.get("data")
if decrypt:
return ts_decrypt(value, self.secret)
return value
def delete(self, table: str, item_id: str) -> bool:
return self.delete_raw(self._doc_id(table, item_id))
def list(self, table: str, decrypt: bool = True) -> List[TeleSecDoc]:
params = {
"include_docs": "true",
"startkey": f'"{table}:"',
"endkey": f'"{table}:\uffff"',
}
res = self._request("GET", "_all_docs", params=params)
if res.status_code >= 400:
raise TeleSecCouchDBError(f"LIST docs failed: {res.status_code} {res.text}")
rows = res.json().get("rows", [])
out: List[TeleSecDoc] = []
for row in rows:
doc = row.get("doc") or {}
item_id = row.get("id", "").split(":", 1)[1] if ":" in row.get("id", "") else row.get("id", "")
value = doc.get("data")
if decrypt:
value = ts_decrypt(value, self.secret)
out.append(TeleSecDoc(id=item_id, data=value, raw=doc))
return out

262
python_sdk/windows_agent.py Normal file
View File

@@ -0,0 +1,262 @@
import argparse
import ctypes
import json
import os
import socket
import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import psutil
try:
from .telesec_couchdb import TeleSecCouchDB, TeleSecCouchDBError, ts_decrypt
except ImportError:
from telesec_couchdb import TeleSecCouchDB, TeleSecCouchDBError, ts_decrypt
def utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def parse_iso(value: str) -> Optional[datetime]:
if not value:
return None
try:
v = value.strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(v)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def get_current_username() -> str:
try:
return psutil.Process().username() or os.getlogin()
except Exception:
try:
return os.getlogin()
except Exception:
return os.environ.get("USERNAME", "")
def _window_title(hwnd: int) -> str:
buf_len = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
if buf_len <= 0:
return ""
buf = ctypes.create_unicode_buffer(buf_len + 1)
ctypes.windll.user32.GetWindowTextW(hwnd, buf, buf_len + 1)
return buf.value or ""
def get_active_app() -> Dict[str, str]:
exe = ""
title = ""
try:
hwnd = ctypes.windll.user32.GetForegroundWindow()
if hwnd:
title = _window_title(hwnd)
pid = ctypes.c_ulong()
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value:
try:
proc = psutil.Process(pid.value)
exe = proc.name() or ""
except Exception:
exe = ""
except Exception:
pass
return {"exe": exe, "title": title}
def build_payload(machine_id: str) -> Dict[str, Any]:
app = get_active_app()
return {
"Hostname": machine_id,
"UsuarioActual": get_current_username(),
"AppActualEjecutable": app.get("exe", ""),
"AppActualTitulo": app.get("title", ""),
# campo local diagnóstico (no se usa para decisión remota)
"AgentLocalSeenAt": utcnow_iso(),
}
def should_shutdown(data: Dict[str, Any], server_now: datetime) -> bool:
target = parse_iso(str(data.get("ShutdownBeforeDate", "") or ""))
if not target:
return False
return server_now >= target
def execute_shutdown(dry_run: bool = False) -> None:
if dry_run:
print("[DRY-RUN] Ejecutaría: shutdown /s /t 0 /f")
return
subprocess.run(["shutdown", "/s", "/t", "0", "/f"], check=False)
def run_once(client: TeleSecCouchDB, machine_id: str, dry_run: bool = False) -> None:
server_now = client.get_server_datetime()
server_now_iso = server_now.isoformat(timespec="milliseconds").replace("+00:00", "Z")
raw = client.get(table="aulas_ordenadores", item_id=machine_id, decrypt=False)
current: Dict[str, Any] = {}
if raw is not None:
current = ts_decrypt(raw, client.secret)
if not isinstance(current, dict):
current = {}
update = build_payload(machine_id)
update["LastSeenAt"] = server_now_iso
for key in ["ShutdownBeforeDate", "ShutdownRequestedAt", "ShutdownRequestedBy"]:
if key in current:
update[key] = current.get(key)
client.put(table="aulas_ordenadores", item_id=machine_id, data=update, encrypt=True)
if should_shutdown(update, server_now):
print(f"[{server_now_iso}] ShutdownBeforeDate alcanzado. Apagando {machine_id}...")
execute_shutdown(dry_run=dry_run)
else:
print(
f"[{server_now_iso}] Reportado {machine_id} user={update.get('UsuarioActual','')} "
f"exe={update.get('AppActualEjecutable','')} title={update.get('AppActualTitulo','')}"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="TeleSec Windows Agent")
parser.add_argument("--server", default="", help="CouchDB server URL, ej. https://couch.example")
parser.add_argument("--db", default="telesec", help="Database name")
parser.add_argument("--user", default="", help="CouchDB username")
parser.add_argument("--password", default="", help="CouchDB password")
parser.add_argument("--secret", default="", help="TeleSec secret para cifrado")
parser.add_argument("--machine-id", default="", help="ID de máquina (default: hostname)")
parser.add_argument("--interval", type=int, default=15, help="Intervalo en segundos")
parser.add_argument("--once", action="store_true", help="Ejecutar una sola iteración")
parser.add_argument("--dry-run", action="store_true", help="No apagar realmente, solo log")
parser.add_argument(
"--config",
default="",
help="Ruta de config JSON (default: ~/.telesec/windows_agent.json)",
)
return parser.parse_args()
def _default_config_path() -> str:
return os.path.join(os.path.expanduser("~"), ".telesec", "windows_agent.json")
def _load_or_init_config(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
default_cfg = {
"server": "https://tu-couchdb",
"db": "telesec",
"user": "",
"password": "",
"secret": "",
"machine_id": "",
"interval": 15,
}
with open(path, "w", encoding="utf-8") as f:
json.dump(default_cfg, f, ensure_ascii=False, indent=2)
return default_cfg
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
return {}
def _save_config(path: str, data: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _pick(cli_value: Any, cfg_value: Any, default_value: Any = None) -> Any:
if cli_value is None:
return cfg_value if cfg_value not in [None, ""] else default_value
if isinstance(cli_value, str):
if cli_value.strip() == "":
return cfg_value if cfg_value not in [None, ""] else default_value
return cli_value
return cli_value
def main() -> int:
args = parse_args()
config_path = args.config or _default_config_path()
try:
cfg = _load_or_init_config(config_path)
except Exception as exc:
print(f"No se pudo cargar/crear config en {config_path}: {exc}", file=sys.stderr)
return 3
server = _pick(args.server, cfg.get("server"), "")
db = _pick(args.db, cfg.get("db"), "telesec")
user = _pick(args.user, cfg.get("user"), "")
password = _pick(args.password, cfg.get("password"), "")
secret = _pick(args.secret, cfg.get("secret"), "")
machine_id = _pick(args.machine_id, cfg.get("machine_id"), "")
interval = _pick(args.interval, cfg.get("interval"), 15)
machine_id = (machine_id or socket.gethostname() or "unknown-host").strip()
if not server or not secret:
print(
"Falta configuración obligatoria. Edita el JSON en: " + config_path,
file=sys.stderr,
)
return 4
# Persist effective parameters for next runs
try:
persistent_cfg = {
"server": server,
"db": db,
"user": user,
"password": password,
"secret": secret,
"machine_id": machine_id,
"interval": int(interval),
}
_save_config(config_path, persistent_cfg)
except Exception as exc:
print(f"No se pudo guardar config en {config_path}: {exc}", file=sys.stderr)
client = TeleSecCouchDB(
server_url=server,
dbname=db,
secret=secret,
username=user or None,
password=password or None,
)
try:
client.check_connection()
except TeleSecCouchDBError as exc:
print(f"Error de conexión CouchDB: {exc}", file=sys.stderr)
return 2
if args.once:
run_once(client=client, machine_id=machine_id, dry_run=args.dry_run)
return 0
while True:
try:
run_once(client=client, machine_id=machine_id, dry_run=args.dry_run)
except Exception as exc:
print(f"Error en iteración agente: {exc}", file=sys.stderr)
time.sleep(max(5, int(interval)))
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1 +1,3 @@
requests
requests
pycryptodome
psutil

View File

@@ -19,6 +19,8 @@ function open_page(params) {
EventListeners.QRScanner = [];
EventListeners.Custom.forEach((ev) => ev());
EventListeners.Custom = [];
EventListeners.DB.forEach((ev) => DB.unlisten(ev));
EventListeners.DB = [];
if (SUB_LOGGED_IN != true && params != 'login,setup' && !params.startsWith('login,onboarding')) {
PAGES['login'].index();
@@ -29,11 +31,16 @@ function open_page(params) {
}
var path = params.split(',');
var app = path[0];
if (!PAGES[app]) {
toastr.error('La app solicitada no existe.');
setUrlHash('index');
return;
}
if (path[1] == undefined) {
PAGES[app].index();
return;
}
PAGES[app].edit(path[1]);
PAGES[app].edit(path.slice(1).join(','));
}
function setUrlHash(hash) {
@@ -50,7 +57,7 @@ function setUrlHash(hash) {
}
}
window.onhashchange = () => {
open_page(location.hash.replace('#', ''));
open_page(location.hash.replace('#', '').split("?")[0]);
};
function download(filename, text) {

View File

@@ -3,6 +3,30 @@ try {
} catch {
console.log('ScreenLock Failed');
}
// Configuración de precios del café (cargado desde DB)
window.PRECIOS_CAFE = {
servicio_base: 10,
leche_pequena: 15,
leche_grande: 25,
cafe: 25,
colacao: 25,
};
// Cargar precios desde la base de datos al iniciar
if (typeof DB !== 'undefined') {
DB.get('config', 'precios_cafe').then((raw) => {
TS_decrypt(raw, SECRET, (precios) => {
if (precios) {
Object.assign(window.PRECIOS_CAFE, precios);
console.log('Precios del café cargados:', window.PRECIOS_CAFE);
}
});
}).catch(() => {
console.log('Usando precios por defecto');
});
}
const debounce = (id, callback, wait, args) => {
// debounce with trailing callback
// First call runs immediately, then locks for 'wait' ms
@@ -31,6 +55,374 @@ const debounce = (id, callback, wait, args) => {
return id;
};
function TS_CreateSearchOverlay(parentEl, options = {}) {
const overlayId = safeuuid();
const panelId = safeuuid();
const inputId = safeuuid();
const closeId = safeuuid();
const clearId = safeuuid();
const overlay = document.createElement('div');
overlay.id = overlayId;
overlay.style.display = 'none';
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.background = 'rgba(0, 0, 0, 0.5)';
overlay.style.zIndex = '9999';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.padding = '16px';
overlay.innerHTML = html`
<div
id="${panelId}"
style="background: white; padding: 12px; border-radius: 8px; width: min(520px, 100%); box-shadow: 0 10px 30px rgba(0,0,0,0.2);"
>
<div style="display: flex; gap: 6px; align-items: center;">
<input
type="text"
id="${inputId}"
placeholder="${options.placeholder || 'Buscar...'}"
style="flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/>
<button type="button" class="btn4" id="${clearId}">Limpiar</button>
<button type="button" class="btn4" id="${closeId}">Cerrar</button>
</div>
</div>
`;
const targetParent = parentEl || document.body;
targetParent.appendChild(overlay);
const inputEl = overlay.querySelector('#' + inputId);
const closeEl = overlay.querySelector('#' + closeId);
const clearEl = overlay.querySelector('#' + clearId);
const panelEl = overlay.querySelector('#' + panelId);
function open() {
overlay.style.display = 'flex';
inputEl.focus();
inputEl.select();
}
function close() {
overlay.style.display = 'none';
}
function setValue(value) {
inputEl.value = value || '';
}
function getValue() {
return inputEl.value || '';
}
inputEl.addEventListener('input', () => {
if (typeof options.onInput === 'function') {
options.onInput(getValue());
}
});
closeEl.addEventListener('click', close);
clearEl.addEventListener('click', () => {
setValue('');
if (typeof options.onInput === 'function') {
options.onInput('');
}
inputEl.focus();
});
overlay.addEventListener('click', (event) => {
if (event.target === overlay && panelEl) {
close();
}
});
return {
open,
close,
setValue,
getValue,
inputEl,
overlayEl: overlay,
};
}
function TS_InitOverlaySearch(container, openBtnId, badgeId, options = {}) {
const debounceId = options.debounceId || safeuuid();
const wait = options.wait || 200;
let currentValue = '';
const badgeEl = badgeId ? document.getElementById(badgeId) : null;
const overlay = TS_CreateSearchOverlay(container, {
placeholder: options.placeholder || 'Buscar...',
onInput: (value) => {
currentValue = (value || '').toLowerCase().trim();
if (badgeEl) {
badgeEl.textContent = currentValue ? `Filtro: "${currentValue}"` : '';
}
if (typeof options.onSearch === 'function') {
debounce(debounceId, options.onSearch, wait, [currentValue]);
}
},
});
if (openBtnId) {
const openBtn = document.getElementById(openBtnId);
if (openBtn) {
openBtn.addEventListener('click', () => overlay.open());
}
}
return {
open: overlay.open,
close: overlay.close,
setValue: overlay.setValue,
getValue: () => currentValue,
getValueRaw: overlay.getValue,
};
}
function TS_normalizePictoValue(value) {
if (!value) {
return { text: '', arasaacId: '' };
}
if (typeof value === 'string') {
return { text: value, arasaacId: '' };
}
if (typeof value === 'object') {
return {
text: value.text || value.nombre || value.name || '',
arasaacId: value.arasaacId || value.id || '',
};
}
return { text: String(value), arasaacId: '' };
}
function TS_buildArasaacPictogramUrl(id) {
return `https://static.arasaac.org/pictograms/${id}/${id}_300.png`;
}
function TS_renderPictoPreview(previewEl, value) {
const target = typeof previewEl === 'string' ? document.getElementById(previewEl) : previewEl;
if (!target) return;
target.innerHTML = '';
if (!value.text && !value.arasaacId) {
const placeholder = document.createElement('b');
placeholder.textContent = 'Seleccionar Pictograma';
target.appendChild(placeholder);
}
if (value.arasaacId) {
const img = document.createElement('img');
img.src = TS_buildArasaacPictogramUrl(value.arasaacId);
img.alt = value.text || 'Pictograma';
img.width = 100;
img.height = 100;
img.loading = 'lazy';
img.style.objectFit = 'contain';
target.appendChild(img);
}
if (value.text) {
const text = document.createElement('span');
text.textContent = value.text;
target.appendChild(text);
}
}
function makePictoStatic(picto) {
var element = document.createElement('div');
element.className = 'picto';
TS_renderPictoPreview(element, picto);
return element.outerHTML;
}
function TS_applyPictoValue(pictoEl, value) {
if (typeof pictoEl === 'string') {
pictoEl = document.getElementById(pictoEl);
}
const plate = TS_normalizePictoValue(value);
pictoEl.dataset.PictoValue = JSON.stringify(plate);
TS_renderPictoPreview(pictoEl, plate);
}
function TS_getPictoValue(pictoEl) {
if (typeof pictoEl === 'string') {
pictoEl = document.getElementById(pictoEl);
}
if (!pictoEl) return { text: '', arasaacId: '' };
const plate = pictoEl.dataset.PictoValue ? JSON.parse(pictoEl.dataset.PictoValue) : { text: '', arasaacId: '' };
return TS_normalizePictoValue(plate);
}
function TS_CreateArasaacSelector(options) {
let panelEl = options.panelEl;
let searchEl = options.searchEl;
let resultsEl = options.resultsEl;
let statusEl = options.statusEl;
let closeEl = options.closeEl;
const debounceId = options.debounceId || safeuuid();
const onPick = typeof options.onPick === 'function' ? options.onPick : () => {};
let activeContext = null;
let overlayEl = null;
if (options.modal === true && !panelEl) {
const overlayId = safeuuid();
const panelId = safeuuid();
const searchId = safeuuid();
const closeId = safeuuid();
const statusId = safeuuid();
const resultsId = safeuuid();
overlayEl = document.createElement('div');
overlayEl.id = overlayId;
overlayEl.style.display = 'none';
overlayEl.style.position = 'fixed';
overlayEl.style.inset = '0';
overlayEl.style.background = 'rgba(0, 0, 0, 0.5)';
overlayEl.style.zIndex = '10000';
overlayEl.style.alignItems = 'center';
overlayEl.style.justifyContent = 'center';
overlayEl.style.padding = '16px';
overlayEl.innerHTML = html`
<div
id="${panelId}"
style="background: white; padding: 12px; border-radius: 8px; width: min(680px, 100%); box-shadow: 0 10px 30px rgba(0,0,0,0.2);"
>
<div style="display: flex; gap: 6px; align-items: center;">
<input
type="text"
id="${searchId}"
placeholder="Buscar pictogramas ARASAAC..."
style="flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/>
<button type="button" class="btn4" id="${closeId}">Cerrar</button>
</div>
<div id="${statusId}" style="margin-top: 6px;"></div>
<div
id="${resultsId}"
style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; max-height: 60vh; overflow: auto;"
></div>
</div>
`;
document.body.appendChild(overlayEl);
panelEl = overlayEl.querySelector('#' + panelId);
searchEl = overlayEl.querySelector('#' + searchId);
resultsEl = overlayEl.querySelector('#' + resultsId);
statusEl = overlayEl.querySelector('#' + statusId);
closeEl = overlayEl.querySelector('#' + closeId);
overlayEl.addEventListener('click', (event) => {
if (event.target === overlayEl) {
close();
}
});
}
function open(context) {
activeContext = context || activeContext;
if (overlayEl) {
overlayEl.style.display = 'flex';
} else if (panelEl) {
panelEl.style.display = 'block';
}
if (searchEl) searchEl.focus();
}
function close() {
if (overlayEl) {
overlayEl.style.display = 'none';
} else if (panelEl) {
panelEl.style.display = 'none';
}
}
function renderResults(items, term) {
if (!resultsEl || !statusEl) return;
resultsEl.innerHTML = '';
if (!items.length) {
statusEl.textContent = `No se encontraron pictogramas para "${term}"`;
return;
}
statusEl.textContent = `${items.length} pictogramas`;
items.slice(0, 60).forEach((item) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.style.display = 'flex';
btn.style.flexDirection = 'column';
btn.style.alignItems = 'center';
btn.style.gap = '4px';
btn.style.padding = '4px';
btn.style.border = '1px solid #ddd';
btn.style.background = 'white';
btn.style.cursor = 'pointer';
const img = document.createElement('img');
img.src = TS_buildArasaacPictogramUrl(item.id);
img.alt = item.label;
img.width = 64;
img.height = 64;
img.loading = 'lazy';
img.style.objectFit = 'contain';
const label = document.createElement('span');
label.style.fontSize = '12px';
label.textContent = item.label;
btn.appendChild(img);
btn.appendChild(label);
btn.onclick = () => {
if (!activeContext) return;
onPick(activeContext, item);
close();
};
resultsEl.appendChild(btn);
});
}
function search(term) {
if (!statusEl || !resultsEl) return;
const trimmed = term.trim();
if (trimmed.length < 2) {
statusEl.textContent = 'Escribe al menos 2 caracteres para buscar.';
resultsEl.innerHTML = '';
return;
}
statusEl.textContent = 'Buscando...';
fetch(`https://api.arasaac.org/api/pictograms/es/search/${encodeURIComponent(trimmed)}`)
.then((res) => res.json())
.then((items) => {
const pictograms = (Array.isArray(items) ? items : [])
.map((item) => {
if (typeof item === 'string' || typeof item === 'number') {
return { id: item, label: trimmed };
}
const id = item._id || item.id;
const keywords = Array.isArray(item.keywords) ? item.keywords : [];
const keyword = keywords[0] ? keywords[0].keyword || keywords[0].name : '';
const label = keyword || item.keyword || item.name || trimmed;
return { id, label };
})
.filter((item) => item.id);
renderResults(pictograms, trimmed);
})
.catch(() => {
statusEl.textContent = 'Error al buscar pictogramas.';
resultsEl.innerHTML = '';
});
}
if (closeEl) {
closeEl.onclick = close;
}
if (searchEl) {
searchEl.addEventListener('input', () =>
debounce(debounceId, search, 300, [searchEl.value])
);
}
return {
open,
close,
};
}
const wheelcolors = [
// Your original custom colors
'#ff0000',
@@ -235,7 +627,8 @@ function addCategory_Personas(
change_cb = () => {},
label = 'Persona',
open_default = false,
default_empty_text = '- Lista Vacia -'
default_empty_text = '- Lista Vacia -',
show_hidden = false,
) {
var details_0 = document.createElement('details'); // children: img_0, summary_0
//details_0.open = true;
@@ -284,6 +677,7 @@ function addCategory_Personas(
.map((entry) => {
var key = entry['_key'];
var value = entry;
if (value.Oculto == true && !show_hidden) { return; }
if (lastreg != value.Region.toUpperCase()) {
lastreg = value.Region.toUpperCase();
var h3_0 = document.createElement('h2');
@@ -700,36 +1094,44 @@ function SC_parse(json) {
}
function SC_parse_short(json) {
var valores = "<small style='font-size: 60%;'>Servicio base (10c)</small>\n";
const precios = window.PRECIOS_CAFE || {
servicio_base: 10,
leche_pequena: 15,
leche_grande: 25,
cafe: 25,
colacao: 25,
};
var valores = `<small style='font-size: 60%;'>Servicio base (${precios.servicio_base}c)</small>\n`;
Object.entries(json).forEach((entry) => {
valores += "<small style='font-size: 60%;'>" + entry[0] + ':</small> ' + entry[1] + ' ';
var combo = entry[0] + ';' + entry[1];
switch (entry[0]) {
case 'Leche':
// Leche pequeña = 10c
// Leche pequeña
if (
json['Tamaño'] == 'Pequeño' &&
['de Vaca', 'Sin lactosa', 'Vegetal', 'Almendras'].includes(json['Leche'])
) {
valores += '<small>(P = 10c)</small>';
valores += `<small>(P = ${precios.leche_pequena}c)</small>`;
}
// Leche grande = 20c
// Leche grande
if (
json['Tamaño'] == 'Grande' &&
['de Vaca', 'Sin lactosa', 'Vegetal', 'Almendras'].includes(json['Leche'])
) {
valores += '<small>(G = 20c)</small>';
valores += `<small>(G = ${precios.leche_grande}c)</small>`;
}
break;
case 'Selección':
// Café = 20c
// Café
if (['Café con leche', 'Solo café (sin leche)'].includes(json['Selección'])) {
valores += '<small>(20c)</small>';
valores += `<small>(${precios.cafe}c)</small>`;
}
// ColaCao = 20c
// ColaCao
if (json['Selección'] == 'ColaCao con leche') {
valores += '<small>(20c)</small>';
valores += `<small>(${precios.colacao}c)</small>`;
}
default:
break;
@@ -743,35 +1145,50 @@ function SC_parse_short(json) {
function SC_priceCalc(json) {
var precio = 0;
var valores = '';
// Servicio base = 10c
precio += 10;
valores += 'Servicio base = 10c\n';
// Leche pequeña = 10c
// Usar precios configurables
const precios = window.PRECIOS_CAFE || {
servicio_base: 10,
leche_pequena: 15,
leche_grande: 25,
cafe: 25,
colacao: 25,
};
// Servicio base
precio += precios.servicio_base;
valores += `Servicio base = ${precios.servicio_base}c\n`;
// Leche pequeña
if (
json['Tamaño'] == 'Pequeño' &&
['de Vaca', 'Sin lactosa', 'Vegetal', 'Almendras'].includes(json['Leche'])
) {
precio += 15;
valores += 'Leche pequeña = 15c\n';
precio += precios.leche_pequena;
valores += `Leche pequeña = ${precios.leche_pequena}c\n`;
}
// Leche grande = 20c
// Leche grande
if (
json['Tamaño'] == 'Grande' &&
['de Vaca', 'Sin lactosa', 'Vegetal', 'Almendras'].includes(json['Leche'])
) {
precio += 25;
valores += 'Leche grande = 25c\n';
precio += precios.leche_grande;
valores += `Leche grande = ${precios.leche_grande}c\n`;
}
// Café = 20c
// Café
if (['Café con leche', 'Solo café (sin leche)'].includes(json['Selección'])) {
precio += 25;
valores += 'Café = 25c\n';
precio += precios.cafe;
valores += `Café = ${precios.cafe}c\n`;
}
// ColaCao = 20c
// ColaCao
if (json['Selección'] == 'ColaCao con leche') {
precio += 25;
valores += 'ColaCao = 25c\n';
precio += precios.colacao;
valores += `ColaCao = ${precios.colacao}c\n`;
}
valores += '<hr>Total: ' + precio + 'c\n';
return [precio, valores];
}
@@ -796,6 +1213,7 @@ function TS_IndexElement(
var searchKeyInput = safeuuid();
var debounce_search = safeuuid();
var debounce_load = safeuuid();
var filter_tr = safeuuid();
// Create the container with search bar and table
container.innerHTML = html`
@@ -809,9 +1227,11 @@ function TS_IndexElement(
id="${searchKeyInput}"
placeholder="🔍 Buscar..."
style="width: calc(100% - 18px); padding: 8px; border: 1px solid #ccc; border-radius: 4px; background-color: rebeccapurple; color: white;"
value=""
/>
</th>
</tr>
<tr id="${filter_tr}"></tr>
<tr id="${tablehead}"></tr>
</thead>
<tbody id="${tablebody}"></tbody>
@@ -828,8 +1248,29 @@ function TS_IndexElement(
// Add search functionality
const searchKeyEl = document.getElementById(searchKeyInput);
searchKeyEl.addEventListener('input', () => debounce(debounce_search, render, 200, [rows]));
// If there is a preset search value in URL, apply it
var hashQuery = new URLSearchParams(window.location.hash.split('?')[1]);
if (hashQuery.has('search')) {
searchKeyEl.value = hashQuery.get('search');
}
var filters = {};
if (hashQuery.has('filter')) {
hashQuery.getAll('filter').forEach((filter) => {
var [key, value] = filter.split(":");
filters[key] = value;
});
document.getElementById(filter_tr).innerHTML = '<th colspan="100%" style="color: #000; background: #fff;">Filtrando por: ' + Object.entries(filters)
.map(([key, value]) => `${key}`)
.join(', ') + ' - <a href=' + window.location.hash.split('?')[0] + '">Limpiar filtros</a></th>';
}
function searchInData(data, searchValue, config) {
if (filters) {
for (var fkey in filters) {
if (data[fkey] != filters[fkey]) {
return false;
}
}
}
if (!searchValue) return true;
// Search in ID
@@ -875,6 +1316,11 @@ function TS_IndexElement(
if (formattedDate.includes(searchValue)) return true;
}
break;
case 'picto': {
const plate = TS_normalizePictoValue(value);
if (plate.text && plate.text.toLowerCase().includes(searchValue)) return true;
break;
}
default:
// For raw and other types, search in the direct value
if (String(value).toLowerCase().includes(searchValue)) return true;
@@ -990,6 +1436,33 @@ function TS_IndexElement(
new_tr.appendChild(tdFechaISO);
break;
}
case 'picto': {
const tdPicto = document.createElement('td');
const plate = TS_normalizePictoValue(data[key.key]);
const wrapper = document.createElement('div');
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'center';
wrapper.style.gap = '8px';
if (plate.arasaacId) {
const img = document.createElement('img');
img.src = TS_buildArasaacPictogramUrl(plate.arasaacId);
img.alt = plate.text || 'Pictograma';
img.width = 48;
img.height = 48;
img.loading = 'lazy';
img.style.objectFit = 'contain';
wrapper.appendChild(img);
}
if (plate.text) {
const text = document.createElement('span');
console.log('Picto data', data, 'normalized', plate);
text.textContent = data[key.labelkey] || plate.text || '';
wrapper.appendChild(text);
}
tdPicto.appendChild(wrapper);
new_tr.appendChild(tdPicto);
break;
}
case 'template': {
const tdCustomTemplate = document.createElement('td');
new_tr.appendChild(tdCustomTemplate);
@@ -1214,7 +1687,7 @@ function TS_IndexElement(
}
// Subscribe to dataset updates using DB.map (PouchDB) when `ref` is a table name string
if (typeof ref === 'string') {
DB.map(ref, (data, key) => {
EventListeners.DB.push(DB.map(ref, (data, key) => {
function add_row(data, key) {
if (data != null) {
data['_key'] = key;
@@ -1229,49 +1702,21 @@ function TS_IndexElement(
data,
SECRET,
(data, wasEncrypted) => {
data['_encrypted__'] = wasEncrypted;
add_row(data, key);
if (data != null && typeof data === 'object') {
data['_encrypted__'] = wasEncrypted;
add_row(data, key);
}
},
ref,
key
);
} else {
data['_encrypted__'] = false;
if (data != null && typeof data === 'object') {
data['_encrypted__'] = false;
}
add_row(data, key);
}
});
} else if (ref && typeof ref.map === 'function') {
// Legacy: try to use ref.map().on if available (for backwards compatibility)
try {
ref.map().on((data, key, _msg, _ev) => {
function add_row(data, key) {
if (data != null) {
data['_key'] = key;
rows[key] = data;
} else {
delete rows[key];
}
debounce(debounce_load, render, 200, [rows]);
}
if (typeof data == 'string') {
TS_decrypt(
data,
SECRET,
(data, wasEncrypted) => {
data['_encrypted__'] = wasEncrypted;
add_row(data, key);
},
undefined,
undefined
);
} else {
data['_encrypted__'] = false;
add_row(data, key);
}
});
} catch (e) {
console.warn('TS_IndexElement: cannot subscribe to ref', e);
}
}));
}
}
@@ -1281,7 +1726,7 @@ function BuildQR(mid, label) {
dim: 150,
pad: 0,
mtx: -1,
ecl: 'L',
ecl: 'S',
ecb: 0,
pal: ['#000000', '#ffffff'],
vrb: 0,
@@ -1299,6 +1744,7 @@ var PAGES = {};
var PERMS = {
ADMIN: 'Administrador',
};
function checkRole(role) {
var roles = SUB_LOGGED_IN_DETAILS.Roles || '';
var rolesArr = roles.split(',');
@@ -1317,7 +1763,7 @@ function SetPages() {
if (PAGES[key].AccessControl == true) {
var roles = SUB_LOGGED_IN_DETAILS.Roles || '';
var rolesArr = roles.split(',');
if (rolesArr.includes('ADMIN') || rolesArr.includes(key) || AC_BYPASS) {
if (rolesArr.includes('ADMIN') || rolesArr.includes(PAGES[key].AccessControlRole || key) || AC_BYPASS) {
} else {
return;
}
@@ -1363,6 +1809,12 @@ if (couchHost) {
}
const statusImg = document.getElementById('connectStatus');
statusImg.onclick = () => {
var ribbon = document.getElementById('ribbon-content');
var alternative_ribbon = document.getElementById('ribbon-content-alternative');
ribbon.style.display = ribbon.style.display === 'none' ? 'block' : 'none';
alternative_ribbon.style.display = alternative_ribbon.style.display === 'none' ? 'block' : 'none';
}
function updateStatusOrb() {
const now = Date.now();
const recentSync = window.TELESEC_LAST_SYNC && now - window.TELESEC_LAST_SYNC <= 3000;
@@ -1433,7 +1885,7 @@ var BootIntervalID = setInterval(() => {
SUB_LOGGED_IN = true;
localStorage.setItem('TELESEC_BYPASS_ID', SUB_LOGGED_IN_ID);
SetPages();
open_page(location.hash.replace('#', ''));
open_page(location.hash.replace('#', '').split("?")[0]);
}
if (!data) {
const persona = { Nombre: 'Admin (bypass)', Roles: 'ADMIN,' };
@@ -1470,7 +1922,7 @@ var BootIntervalID = setInterval(() => {
}
} else {
SetPages();
open_page(location.hash.replace('#', ''));
open_page(location.hash.replace('#', '').split("?")[0]);
}
clearInterval(BootIntervalID);
}

View File

@@ -8,6 +8,7 @@ var EventListeners = {
Interval: [],
QRScanner: [],
Custom: [],
DB: [],
};
// Safe UUID for html element IDs: generates a unique identifier with a specified prefix, ensuring it is safe for use in HTML element IDs. It uses crypto.randomUUID if available, with a fallback to a random string generation method for environments that do not support it. The generated ID is prefixed to avoid collisions and ensure uniqueness across the application.
@@ -81,7 +82,7 @@ if (urlParams.get('couch') != null) {
history.replaceState(
null,
'',
location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '') + location.hash
location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '') + location.hash.split("?")[0]
);
console.log('CouchDB auto-configured from URL parameter');
@@ -127,3 +128,19 @@ function LogOutTeleSec() {
history.replaceState(null, '', '?' + urlParams.toString());
location.reload();
}
var TTS_RATE = parseFloat(urlParams.get('tts_rate')) || 0.75;
function TS_SayTTS(msg) {
try {
if (window.speechSynthesis) {
let utterance = new SpeechSynthesisUtterance(msg);
utterance.rate = TTS_RATE;
speechSynthesis.speak(utterance);
}
} catch { console.warn('TTS error'); }
}
function createElementFromHTML(htmlString) {
var div = document.createElement('div');
div.innerHTML = htmlString.trim();
return div.firstChild;
}

View File

@@ -9,7 +9,8 @@ var DB = (function () {
let changes = null;
let repPush = null;
let repPull = null;
let callbacks = {}; // table -> [cb]
let callbacks = {}; // table -> [{ id, cb }]
let callbackSeq = 0;
let docCache = {}; // _id -> last data snapshot (stringified)
function ensureLocal() {
@@ -34,6 +35,11 @@ var DB = (function () {
return table + ':' + id;
}
function makeCallbackId(table) {
callbackSeq += 1;
return table + '#' + callbackSeq;
}
function init(opts) {
const localName = 'telesec';
try {
@@ -126,7 +132,8 @@ var DB = (function () {
if (change.deleted || doc._deleted) {
delete docCache[doc._id];
if (callbacks[table]) {
callbacks[table].forEach((cb) => {
callbacks[table].forEach((listener) => {
const cb = listener.cb;
try {
cb(null, id);
} catch (e) {
@@ -148,7 +155,8 @@ var DB = (function () {
}
if (callbacks[table]) {
callbacks[table].forEach((cb) => {
callbacks[table].forEach((listener) => {
const cb = listener.cb;
try {
cb(doc.data, id);
} catch (e) {
@@ -348,12 +356,25 @@ var DB = (function () {
function map(table, cb) {
ensureLocal();
const callbackId = makeCallbackId(table);
callbacks[table] = callbacks[table] || [];
callbacks[table].push(cb);
list(table).then((rows) => rows.forEach((r) => cb(r.data, r.id)));
return () => {
callbacks[table] = callbacks[table].filter((x) => x !== cb);
};
callbacks[table].push({ id: callbackId, cb: cb });
list(table).then((rows) => {
const stillListening = (callbacks[table] || []).some((listener) => listener.id === callbackId);
if (!stillListening) return;
rows.forEach((r) => cb(r.data, r.id));
});
return callbackId;
}
function unlisten(callbackId) {
if (!callbackId) return false;
for (const table of Object.keys(callbacks)) {
const before = callbacks[table].length;
callbacks[table] = callbacks[table].filter((listener) => listener.id !== callbackId);
if (callbacks[table].length !== before) return true;
}
return false;
}
return {
@@ -363,6 +384,7 @@ var DB = (function () {
del,
list,
map,
unlisten,
replicateToRemote,
listAttachments,
deleteAttachment,

View File

@@ -14,9 +14,9 @@
<body>
<div class="ribbon no_print" id="header_hide_query">
<img class="ribbon-orb" id="connectStatus" src="static/logo.jpg" />
<img class="ribbon-orb" id="connectStatus" src="static/logo.jpg" style="cursor: pointer;" />
<div class="ribbon-content">
<div class="ribbon-content" id="ribbon-content" style="display: none;">
<div class="ribbon-tabs">
<div class="ribbon-tab active" data-tab="modulos">Modulos</div>
<div class="ribbon-tab" data-tab="buscar">Buscar</div>
@@ -43,6 +43,10 @@
<small style="margin-top:10px;">Base de datos: <b id="peerLink">(no configurado)</b></small>
</div>
<div class="ribbon-content" id="ribbon-content-alternative">
<h2 style="margin: 0;">TeleSec, muy seguro. </h2>
<i><small>(pulsa el icono para mostrar el menú)</small></i>
</div>
</div>
<img id="loading" src="load.gif" style="display: block; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: calc(100% - 50px); max-width: 400px;" />
@@ -70,6 +74,7 @@
<script src="static/pouchdb.min.js"></script>
<script src="static/toastr.min.js"></script>
<script src="static/doublescroll.js"></script>
<script src="static/chart.umd.min.js"></script>
<script src="pwa.js"></script>
<script src="config.js"></script>
<script src="db.js"></script>
@@ -86,6 +91,8 @@
<!-- <script src="page/avisos.js"></script> -->
<script src="page/comedor.js"></script>
<script src="page/notas.js"></script>
<script src="page/mensajes.js"></script>
<script src="page/panel.js"></script>
<!-- <script src="page/chat.js"></script> -->
<script src="page/buscar.js"></script>
<script src="page/pagos.js"></script>

View File

@@ -48,6 +48,9 @@ PAGES.aulas = {
<a class="button btn4" style="font-size: 25px;" href="#supercafe"
><img src="${PAGES.supercafe.icon}" height="20" /> Ver comandas</a
>
<a class="button btn8" style="font-size: 25px;" href="#aulas,ordenadores"
><img src="${PAGES.aulas.icon}" height="20" /> Control de ordenadores</a
>
</fieldset>
<fieldset style="float: left;">
<legend>Datos de hoy</legend>
@@ -451,12 +454,218 @@ Cargando...</pre
}
};
},
edit: function (section) {
__decryptIfNeeded: function (table, id, raw) {
return new Promise((resolve) => {
if (typeof raw !== 'string') {
resolve(raw || {});
return;
}
TS_decrypt(
raw,
SECRET,
(data) => {
resolve(data || {});
},
table,
id
);
});
},
__getServerNow: async function () {
try {
var couchUrl = (localStorage.getItem('TELESEC_COUCH_URL') || '').replace(/\/$/, '');
var couchUser = localStorage.getItem('TELESEC_COUCH_USER') || '';
var couchPass = localStorage.getItem('TELESEC_COUCH_PASS') || '';
var couchDb = localStorage.getItem('TELESEC_COUCH_DBNAME') || 'telesec';
if (couchUrl) {
var target = couchUrl + '/' + encodeURIComponent(couchDb);
var headers = {};
if (couchUser) {
headers['Authorization'] = 'Basic ' + btoa(couchUser + ':' + couchPass);
}
var res = await fetch(target, { method: 'HEAD', headers: headers });
var dateHeader = res.headers.get('Date');
if (dateHeader) {
var dt = new Date(dateHeader);
if (!isNaN(dt.getTime())) return dt;
}
}
} catch (e) {
console.warn('No se pudo obtener hora desde CouchDB', e);
}
try {
var wres = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
if (wres.ok) {
var wjson = await wres.json();
if (wjson && wjson.utc_datetime) {
var wdt = new Date(wjson.utc_datetime);
if (!isNaN(wdt.getTime())) return wdt;
}
}
} catch (e2) {
console.warn('No se pudo obtener hora desde worldtimeapi', e2);
}
return new Date();
},
__scheduleShutdown: async function (machineId) {
try {
document.getElementById('actionStatus').style.display = 'block';
var serverNow = await PAGES.aulas.__getServerNow();
var shutdownAt = new Date(serverNow.getTime() + 2 * 60 * 1000).toISOString();
var raw = await DB.get('aulas_ordenadores', machineId);
var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', machineId, raw);
data = data || {};
data.Hostname = data.Hostname || machineId;
data.ShutdownBeforeDate = shutdownAt;
data.ShutdownRequestedAt = serverNow.toISOString();
data.ShutdownRequestedBy = SUB_LOGGED_IN_ID || '';
await DB.put('aulas_ordenadores', machineId, data);
toastr.warning('Apagado programado antes de: ' + shutdownAt);
} catch (e) {
console.warn('Error programando apagado remoto', e);
toastr.error('No se pudo programar el apagado remoto');
} finally {
document.getElementById('actionStatus').style.display = 'none';
}
},
__cancelShutdown: async function (machineId) {
try {
document.getElementById('actionStatus').style.display = 'block';
var raw = await DB.get('aulas_ordenadores', machineId);
var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', machineId, raw);
data = data || {};
data.Hostname = data.Hostname || machineId;
data.ShutdownBeforeDate = '';
data.ShutdownRequestedAt = '';
data.ShutdownRequestedBy = '';
await DB.put('aulas_ordenadores', machineId, data);
toastr.success('Apagado remoto cancelado');
} catch (e) {
console.warn('Error cancelando apagado remoto', e);
toastr.error('No se pudo cancelar el apagado remoto');
} finally {
document.getElementById('actionStatus').style.display = 'none';
}
},
_ordenadores: function () {
container.innerHTML = html`
<a class="button" href="#aulas">← Volver a Gestión de Aulas</a>
<h1>Control de ordenadores</h1>
<p>
Estado enviado por el agente Windows. El apagado remoto se programa con hora de servidor.
</p>
<div id="cont"></div>
`;
TS_IndexElement(
'aulas,ordenadores',
[
{ key: 'Hostname', type: 'raw', default: '', label: 'Hostname' },
{ key: 'UsuarioActual', type: 'raw', default: '', label: 'Usuario actual' },
{ key: 'AppActualEjecutable', type: 'raw', default: '', label: 'App actual (exe)' },
{ key: 'AppActualTitulo', type: 'raw', default: '', label: 'App actual (título)' },
{ key: 'LastSeenAt', type: 'raw', default: '', label: 'Último visto (server)' },
{
key: 'ShutdownBeforeDate',
type: 'template',
label: 'Apagado remoto',
template: (data, td) => {
var text = document.createElement('div');
text.style.marginBottom = '6px';
text.innerText = data.ShutdownBeforeDate
? '⏻ Antes de: ' + data.ShutdownBeforeDate
: 'Sin apagado programado';
td.appendChild(text);
var btnOn = document.createElement('button');
btnOn.className = 'rojo';
btnOn.innerText = 'Programar +2m';
btnOn.onclick = async (event) => {
event.preventDefault();
event.stopPropagation();
await PAGES.aulas.__scheduleShutdown(data._key);
return false;
};
td.appendChild(btnOn);
if (data.ShutdownBeforeDate) {
td.appendChild(document.createElement('br'));
var btnCancel = document.createElement('button');
btnCancel.className = 'btn5';
btnCancel.innerText = 'Cancelar';
btnCancel.onclick = async (event) => {
event.preventDefault();
event.stopPropagation();
await PAGES.aulas.__cancelShutdown(data._key);
return false;
};
td.appendChild(btnCancel);
}
},
},
],
'aulas_ordenadores',
document.querySelector('#cont')
);
},
_ordenadores__edit: function (mid) {
var field_host = safeuuid();
var field_user = safeuuid();
var field_exe = safeuuid();
var field_title = safeuuid();
var field_seen = safeuuid();
var field_shutdown = safeuuid();
var btn_schedule = safeuuid();
var btn_cancel = safeuuid();
container.innerHTML = html`
<a class="button" href="#aulas,ordenadores">← Volver a ordenadores</a>
<h1>Ordenador <code>${mid}</code></h1>
<fieldset style="float: none; width: calc(100% - 40px);max-width: none;">
<legend>Estado</legend>
<label>Hostname<br /><input readonly id="${field_host}" /></label><br /><br />
<label>Usuario actual<br /><input readonly id="${field_user}" /></label><br /><br />
<label>App actual (exe)<br /><input readonly id="${field_exe}" /></label><br /><br />
<label>App actual (título)<br /><input readonly id="${field_title}" /></label><br /><br />
<label>Último visto (server)<br /><input readonly id="${field_seen}" /></label><br /><br />
<label>ShutdownBeforeDate<br /><input readonly id="${field_shutdown}" /></label><br /><br />
<button class="rojo" id="${btn_schedule}">Programar apagado +2m</button>
<button class="btn5" id="${btn_cancel}">Cancelar apagado</button>
</fieldset>
`;
async function loadData() {
var raw = await DB.get('aulas_ordenadores', mid);
var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', mid, raw);
data = data || {};
document.getElementById(field_host).value = data.Hostname || mid;
document.getElementById(field_user).value = data.UsuarioActual || '';
document.getElementById(field_exe).value = data.AppActualEjecutable || '';
document.getElementById(field_title).value = data.AppActualTitulo || '';
document.getElementById(field_seen).value = data.LastSeenAt || '';
document.getElementById(field_shutdown).value = data.ShutdownBeforeDate || '';
}
loadData();
document.getElementById(btn_schedule).onclick = async () => {
await PAGES.aulas.__scheduleShutdown(mid);
await loadData();
};
document.getElementById(btn_cancel).onclick = async () => {
await PAGES.aulas.__cancelShutdown(mid);
await loadData();
};
},
edit: function (fsection) {
if (!checkRole('aulas')) {
setUrlHash('index');
return;
}
var item = location.hash.replace('#', '').split(',')[2];
var section = fsection.split(',')[0];
var item = location.hash.replace('#', '').split("?")[0].split(',')[2];
if (!item) {
// No item, show section
switch (section) {
@@ -466,6 +675,9 @@ Cargando...</pre
case 'informes':
this._informes();
break;
case 'ordenadores':
this._ordenadores();
break;
default:
this.index();
break;
@@ -479,6 +691,9 @@ Cargando...</pre
case 'informes':
this._informes__edit(item);
break;
case 'ordenadores':
this._ordenadores__edit(item);
break;
}
}
},

View File

@@ -5,6 +5,66 @@ PAGES.comedor = {
icon: 'static/appico/apple.png',
AccessControl: true,
Title: 'Comedor',
__cleanupOldMenus: async function () {
try {
var rows = await DB.list('comedor');
var now = new Date();
var todayUTC = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
var removed = 0;
function parseISODateToUTC(value) {
if (!value || typeof value !== 'string') return null;
var match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
var y = parseInt(match[1], 10);
var m = parseInt(match[2], 10) - 1;
var d = parseInt(match[3], 10);
return Date.UTC(y, m, d);
}
async function getFechaFromRow(row) {
var data = row.data;
if (typeof data === 'string') {
return await new Promise((resolve) => {
TS_decrypt(
data,
SECRET,
(decrypted) => {
if (decrypted && typeof decrypted === 'object') {
resolve(decrypted.Fecha || row.id.split(',')[0] || '');
} else {
resolve(row.id.split(',')[0] || '');
}
},
'comedor',
row.id
);
});
}
if (data && typeof data === 'object') {
return data.Fecha || row.id.split(',')[0] || '';
}
return row.id.split(',')[0] || '';
}
for (const row of rows) {
var fecha = await getFechaFromRow(row);
var rowUTC = parseISODateToUTC(fecha);
if (rowUTC == null) continue;
var ageDays = Math.floor((todayUTC - rowUTC) / 86400000);
if (ageDays >= 30) {
await DB.del('comedor', row.id);
removed += 1;
}
}
if (removed > 0) {
toastr.info('Limpieza automática: ' + removed + ' menús antiguos eliminados.');
}
} catch (e) {
console.warn('Comedor cleanup error', e);
}
},
edit: function (mid) {
if (!checkRole('comedor:edit')) {
setUrlHash('comedor');
@@ -12,7 +72,14 @@ PAGES.comedor = {
}
var nameh1 = safeuuid();
var field_fecha = safeuuid();
var field_platos = safeuuid();
var field_tipo = safeuuid();
var field_primero = safeuuid();
var field_segundo = safeuuid();
var field_postre = safeuuid();
var btn_picto_primero = safeuuid();
var btn_picto_segundo = safeuuid();
var btn_picto_postre = safeuuid();
var debounce_picto = safeuuid();
var btn_guardar = safeuuid();
var btn_borrar = safeuuid();
container.innerHTML = html`
@@ -24,18 +91,69 @@ PAGES.comedor = {
<input type="date" id="${field_fecha}" value="" /><br /><br />
</label>
<label>
Platos<br />
<textarea id="${field_platos}"></textarea><br /><br />
Tipo<br />
<input type="text" id="${field_tipo}" value="" /><br /><br />
</label>
<button class="btn5" id="${btn_guardar}">Guardar</button>
<button class="rojo" id="${btn_borrar}">Borrar</button>
<label>
Primero<br />
<input type="text" id="${field_primero}" value="" /><br />
<div class="picto" id="${btn_picto_primero}"></div>
</label>
<label>
Segundo<br />
<input type="text" id="${field_segundo}" value="" /><br />
<div class="picto" id="${btn_picto_segundo}"></div>
</label>
<label>
Postre<br />
<input type="text" id="${field_postre}" value="" /><br />
<div class="picto" id="${btn_picto_postre}"></div>
</label>
<button class="saveico" id="${btn_guardar}">
<img src="static/floppy_disk_green.png" />
<br>Guardar
</button>
<button class="delico" id="${btn_borrar}">
<img src="static/garbage.png" />
<br>Borrar
</button>
<button class="opicon" onclick="setUrlHash('comedor')" style="float: right;"> <!-- Align to the right -->
<img src="static/exit.png" />
<br>Salir
</button>
<button class="opicon" onclick="window.print()" style="float: right;"> <!-- Align to the right -->
<img src="static/printer2.png" />
<br>Imprimir
</button>
</fieldset>
`;
const pictogramSelector = TS_CreateArasaacSelector({
modal: true,
debounceId: debounce_picto,
onPick: (context, item) => {
TS_applyPictoValue(context.pictoId, {
text: item.label,
arasaacId: String(item.id),
});
},
});
document.getElementById(btn_picto_primero).onclick = () =>
pictogramSelector.open({ pictoId: btn_picto_primero });
document.getElementById(btn_picto_segundo).onclick = () =>
pictogramSelector.open({ pictoId: btn_picto_segundo });
document.getElementById(btn_picto_postre).onclick = () =>
pictogramSelector.open({ pictoId: btn_picto_postre });
DB.get('comedor', mid).then((data) => {
function load_data(data, ENC = '') {
document.getElementById(nameh1).innerText = mid;
document.getElementById(field_fecha).value = data['Fecha'] || mid || CurrentISODate();
document.getElementById(field_platos).value = data['Platos'] || '';
document.getElementById(field_tipo).value = data['Tipo'] || '';
document.getElementById(field_primero).value = data['Primero'] || '';
document.getElementById(field_segundo).value = data['Segundo'] || '';
document.getElementById(field_postre).value = data['Postre'] || '';
TS_applyPictoValue(btn_picto_primero, data['Primero_Picto'] || '');
TS_applyPictoValue(btn_picto_segundo, data['Segundo_Picto'] || '');
TS_applyPictoValue(btn_picto_postre, data['Postre_Picto'] || '');
}
if (typeof data == 'string') {
TS_decrypt(
@@ -60,18 +178,25 @@ PAGES.comedor = {
guardarBtn.style.opacity = '0.5';
const newDate = document.getElementById(field_fecha).value;
const newTipo = document.getElementById(field_tipo).value.trim();
var data = {
Fecha: newDate,
Platos: document.getElementById(field_platos).value,
Tipo: newTipo,
Primero: document.getElementById(field_primero).value.trim(),
Segundo: document.getElementById(field_segundo).value.trim(),
Postre: document.getElementById(field_postre).value.trim(),
Primero_Picto: TS_getPictoValue(btn_picto_primero),
Segundo_Picto: TS_getPictoValue(btn_picto_segundo),
Postre_Picto: TS_getPictoValue(btn_picto_postre),
};
// If the date has changed, we need to delete the old entry
if (mid !== newDate && mid !== '') {
if (mid !== newDate + "," + newTipo && mid !== '') {
DB.del('comedor', mid);
}
document.getElementById('actionStatus').style.display = 'block';
DB.put('comedor', newDate, data)
DB.put('comedor', newDate + "," + newTipo, data)
.then(() => {
toastr.success('Guardado!');
setTimeout(() => {
@@ -110,31 +235,56 @@ PAGES.comedor = {
<button id="${btn_new}">Nueva entrada</button>
<div id="${cont}"></div>
`;
TS_IndexElement(
'comedor',
[
{
key: 'Fecha',
type: 'raw',
default: '',
label: 'Fecha',
},
{
key: 'Platos',
type: 'raw',
default: '',
label: 'Platos',
},
],
'comedor',
document.getElementById(cont),
(data, new_tr) => {
// new_tr.style.backgroundColor = "#FFCCCB";
if (data.Fecha == CurrentISODate()) {
new_tr.style.backgroundColor = 'lightgreen';
var renderList = () => {
TS_IndexElement(
'comedor',
[
{
key: 'Fecha',
type: 'raw',
default: '',
label: 'Fecha',
},
{
key: 'Tipo',
type: 'raw',
default: '',
label: 'Tipo',
},
{
key: 'Primero_Picto',
type: 'picto',
default: '',
label: 'Primero',
labelkey: 'Primero',
},
{
key: 'Segundo_Picto',
type: 'picto',
default: '',
label: 'Segundo',
labelkey: 'Segundo',
},
{
key: 'Postre_Picto',
type: 'picto',
default: '',
label: 'Postre',
labelkey: 'Postre',
},
],
'comedor',
document.getElementById(cont),
(data, new_tr) => {
// new_tr.style.backgroundColor = "#FFCCCB";
if (data.Fecha == CurrentISODate()) {
new_tr.style.backgroundColor = 'lightgreen';
}
}
}
);
);
};
PAGES.comedor.__cleanupOldMenus().finally(renderList);
if (!checkRole('comedor:edit')) {
document.getElementById(btn_new).style.display = 'none';

View File

@@ -17,6 +17,9 @@ PAGES.dataman = {
case 'labels':
PAGES.dataman.__labels();
break;
case 'precios':
PAGES.dataman.__precios();
break;
default:
// Tab to edit
}
@@ -209,12 +212,88 @@ PAGES.dataman = {
}
});
},
__precios: function () {
var form = safeuuid();
// Cargar precios actuales desde DB
DB.get('config', 'precios_cafe').then((raw) => {
TS_decrypt(raw, SECRET, (precios) => {
container.innerHTML = html`
<h1>Configuración de Precios del Café</h1>
<form id="${form}">
<fieldset>
<legend>Precios Base (en céntimos)</legend>
<label>
<b>Servicio base:</b>
<input type="number" name="servicio_base" value="${precios.servicio_base || 10}" min="0" step="1" />
céntimos
</label>
<br><br>
<label>
<b>Leche pequeña:</b>
<input type="number" name="leche_pequena" value="${precios.leche_pequena || 15}" min="0" step="1" />
céntimos
</label>
<br><br>
<label>
<b>Leche grande:</b>
<input type="number" name="leche_grande" value="${precios.leche_grande || 25}" min="0" step="1" />
céntimos
</label>
<br><br>
<label>
<b>Café:</b>
<input type="number" name="cafe" value="${precios.cafe || 25}" min="0" step="1" />
céntimos
</label>
<br><br>
<label>
<b>ColaCao:</b>
<input type="number" name="colacao" value="${precios.colacao || 25}" min="0" step="1" />
céntimos
</label>
</fieldset>
<br>
<button type="submit">💾 Guardar precios</button>
<button type="button" onclick="setUrlHash('dataman')">🔙 Volver</button>
</form>
`;
document.getElementById(form).onsubmit = (ev) => {
ev.preventDefault();
var formData = new FormData(document.getElementById(form));
var nuevosPrecios = {
servicio_base: parseInt(formData.get('servicio_base')) || 10,
leche_pequena: parseInt(formData.get('leche_pequena')) || 15,
leche_grande: parseInt(formData.get('leche_grande')) || 25,
cafe: parseInt(formData.get('cafe')) || 25,
colacao: parseInt(formData.get('colacao')) || 25,
};
DB.put('config', 'precios_cafe', nuevosPrecios).then(() => {
toastr.success('Precios guardados correctamente');
// Actualizar variable global
if (window.PRECIOS_CAFE) {
Object.assign(window.PRECIOS_CAFE, nuevosPrecios);
}
setTimeout(() => setUrlHash('dataman'), 1000);
}).catch((e) => {
toastr.error('Error al guardar precios: ' + e.message);
});
};
});
}).catch(() => {
// Si no hay precios guardados, usar valores por defecto
PAGES.dataman.__precios();
});
},
index: function () {
container.innerHTML = html`
<h1>Administración de datos</h1>
<a class="button" href="#dataman,import">Importar datos</a>
<a class="button" href="#dataman,export">Exportar datos</a>
<a class="button" href="#dataman,labels">Imprimir etiquetas</a>
<a class="button" href="#dataman,precios">⚙️ Precios del café</a>
<a class="button" href="#dataman,config">Ajustes</a>
`;
},

View File

@@ -3,16 +3,299 @@ PAGES.index = {
Title: 'Inicio',
icon: 'static/appico/house.png',
index: function () {
var div_stats = safeuuid();
container.innerHTML = html`
<h1>¡Hola, ${SUB_LOGGED_IN_DETAILS.Nombre}!<br />Bienvenidx a %%TITLE%%</h1>
<h2>
Tienes ${parseFloat(SUB_LOGGED_IN_DETAILS.Monedero_Balance).toPrecision(2)} € en el
monedero.
</h2>
<details style="border: 2px solid black; padding: 15px; border-radius: 10px;">
<summary>Estadisticas</summary>
<div
id="${div_stats}"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 15px; margin-bottom: 20px;"
></div>
</details>
<em>Utiliza el menú superior para abrir un modulo</em>
<br /><br />
<button class="btn1" onclick="ActualizarProgramaTeleSec()">Actualizar programa</button>
<button class="btn1" onclick="LogOutTeleSec()">Cerrar sesión</button>
`;
if (checkRole('pagos')) {
var total_ingresos = safeuuid();
var total_gastos = safeuuid();
var balance_total = safeuuid();
var total_ingresos_srcel = html`
<div
style="background: linear-gradient(135deg, #2ed573, #26d063); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">Pagos</span><br>
<h3 style="margin: 0;">Total Ingresos</h3>
<div id="${total_ingresos}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0.00€
</div>
</div>
`;
var total_gastos_srcel = html`
<div
style="background: linear-gradient(135deg, #ff4757, #ff3838); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">Pagos</span><br>
<h3 style="margin: 0;">Total Gastos</h3>
<div id="${total_gastos}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0.00€
</div>
</div>
`;
var balance_total_srcel = html`
<div
style="background: linear-gradient(135deg, #667eea, #764ba2); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">Pagos</span><br>
<h3 style="margin: 0;">Balance Total</h3>
<div id="${balance_total}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0.00€
</div>
</div>
`;
document.getElementById(div_stats).appendChild(createElementFromHTML(total_ingresos_srcel));
document.getElementById(div_stats).appendChild(createElementFromHTML(total_gastos_srcel));
document.getElementById(div_stats).appendChild(createElementFromHTML(balance_total_srcel));
let totalData = {
ingresos: {},
gastos: {},
};
EventListeners.DB.push(
DB.map('pagos', (data, key) => {
function applyData(row) {
if (!row || typeof row !== 'object') {
delete totalData.ingresos[key];
delete totalData.gastos[key];
} else {
const monto = parseFloat(row.Monto || 0) || 0;
const tipo = row.Tipo;
if (tipo === 'Ingreso') {
if (row.Origen != 'Promo Bono') {
totalData.gastos[key] = 0;
totalData.ingresos[key] = monto;
}
} else if (tipo === 'Gasto') {
totalData.ingresos[key] = 0;
totalData.gastos[key] = monto;
} else {
totalData.ingresos[key] = 0;
totalData.gastos[key] = 0;
}
}
const totalIngresos = Object.values(totalData.ingresos).reduce((a, b) => a + b, 0);
const totalGastos = Object.values(totalData.gastos).reduce((a, b) => a + b, 0);
document.getElementById(total_ingresos).innerText = totalIngresos.toFixed(2) + '€';
document.getElementById(total_gastos).innerText = totalGastos.toFixed(2) + '€';
}
if (typeof data == 'string') {
TS_decrypt(data, SECRET, (decoded) => {
applyData(decoded);
});
} else {
applyData(data);
}
})
);
EventListeners.Interval.push(
setInterval(() => {
var balanceReal = 0;
Object.values(SC_Personas).forEach((persona) => {
balanceReal += parseFloat(persona.Monedero_Balance || 0);
});
document.getElementById(balance_total).innerText = balanceReal.toFixed(2) + '€';
document.getElementById(balance_total).style.color =
balanceReal >= 0 ? 'white' : '#ffcccc';
}, 1000)
);
}
if (checkRole('mensajes')) {
var mensajes_sin_leer = safeuuid();
var mensajes_sin_leer_srcel = html`
<div
style="background: linear-gradient(135deg, #66380d, #a5570d); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">Mensajes</span><br>
<h3 style="margin: 0;">Sin leer</h3>
<div id="${mensajes_sin_leer}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0
</div>
</div>
`;
document.getElementById(div_stats).appendChild(createElementFromHTML(mensajes_sin_leer_srcel));
var unreadById = {};
EventListeners.DB.push(
DB.map('mensajes', (data, key) => {
function applyUnread(row) {
if (!row || typeof row !== 'object') {
delete unreadById[key];
} else {
var estado = String(row.Estado || '').trim().toLowerCase();
var isRead = estado === 'leido' || estado === 'leído';
unreadById[key] = isRead ? 0 : 1;
}
var totalUnread = Object.values(unreadById).reduce((a, b) => a + b, 0);
document.getElementById(mensajes_sin_leer).innerText = String(totalUnread);
}
if (typeof data == 'string') {
TS_decrypt(data, SECRET, (decoded) => {
applyUnread(decoded);
});
} else {
applyUnread(data);
}
})
);
}
if (checkRole('supercafe')) {
var comandas_en_deuda = safeuuid();
var comandas_en_deuda_srcel = html`
<div
style="background: linear-gradient(135deg, #8e44ad, #6c3483); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">SuperCafé</span><br>
<h3 style="margin: 0;">Comandas en deuda</h3>
<div id="${comandas_en_deuda}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0
</div>
</div>
`;
document.getElementById(div_stats).appendChild(createElementFromHTML(comandas_en_deuda_srcel));
var deudaById = {};
EventListeners.DB.push(
DB.map('supercafe', (data, key) => {
function applyDeuda(row) {
if (!row || typeof row !== 'object') {
delete deudaById[key];
} else {
var estado = String(row.Estado || '').trim().toLowerCase();
deudaById[key] = estado === 'deuda' ? 1 : 0;
}
var totalDeuda = Object.values(deudaById).reduce((a, b) => a + b, 0);
document.getElementById(comandas_en_deuda).innerText = String(totalDeuda);
}
if (typeof data == 'string') {
TS_decrypt(data, SECRET, (decoded) => {
applyDeuda(decoded);
});
} else {
applyDeuda(data);
}
})
);
}
if (checkRole('materiales')) {
var materiales_comprar = safeuuid();
var materiales_revisar = safeuuid();
var materiales_comprar_srcel = html`
<div
style="background: linear-gradient(135deg, #e67e22, #d35400); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">Almacén</span><br>
<h3 style="margin: 0;">Por comprar</h3>
<div id="${materiales_comprar}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0
</div>
</div>
`;
var materiales_revisar_srcel = html`
<div
style="background: linear-gradient(135deg, #2980b9, #1f6391); padding: 20px; border-radius: 10px; text-align: center; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"
>
<span style="font-size: 16px;">Almacén</span><br>
<h3 style="margin: 0;">Por revisar</h3>
<div id="${materiales_revisar}" style="font-size: 32px; font-weight: bold; margin-top: 10px;">
0
</div>
</div>
`;
document.getElementById(div_stats).appendChild(createElementFromHTML(materiales_comprar_srcel));
document.getElementById(div_stats).appendChild(createElementFromHTML(materiales_revisar_srcel));
var comprarById = {};
var revisarById = {};
EventListeners.DB.push(
DB.map('materiales', (data, key) => {
function applyMaterialStats(row) {
if (!row || typeof row !== 'object') {
delete comprarById[key];
delete revisarById[key];
} else {
var cantidad = parseFloat(row.Cantidad);
var cantidadMinima = parseFloat(row.Cantidad_Minima);
var lowStock = !isNaN(cantidad) && !isNaN(cantidadMinima) && cantidad < cantidadMinima;
comprarById[key] = lowStock ? 1 : 0;
var revision = String(row.Revision || '?').trim();
var needsReview = false;
if (revision === '?' || revision === '' || revision === '-') {
needsReview = true;
} else {
var match = revision.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
needsReview = true;
} else {
var y = parseInt(match[1], 10);
var m = parseInt(match[2], 10) - 1;
var d = parseInt(match[3], 10);
var revisionMs = Date.UTC(y, m, d);
var now = new Date();
var todayMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
var diffDays = Math.floor((todayMs - revisionMs) / 86400000);
needsReview = diffDays >= 90;
}
}
revisarById[key] = needsReview ? 1 : 0;
}
var totalComprar = Object.values(comprarById).reduce((a, b) => a + b, 0);
var totalRevisar = Object.values(revisarById).reduce((a, b) => a + b, 0);
document.getElementById(materiales_comprar).innerText = String(totalComprar);
document.getElementById(materiales_revisar).innerText = String(totalRevisar);
}
if (typeof data == 'string') {
TS_decrypt(data, SECRET, (decoded) => {
applyMaterialStats(decoded);
});
} else {
applyMaterialStats(data);
}
})
);
}
},
edit: function (mid) {
switch (mid) {

View File

@@ -421,11 +421,11 @@ PAGES.login = {
SUB_LOGGED_IN_DETAILS = SC_Personas[SUB_LOGGED_IN_ID];
SUB_LOGGED_IN = true;
SetPages();
if (location.hash.replace('#', '').startsWith('login')) {
if (location.hash.replace('#', '').split("?")[0].startsWith('login')) {
open_page('index');
setUrlHash('index');
} else {
open_page(location.hash.replace('#', ''));
open_page(location.hash.replace('#', '').split("?")[0]);
}
};

View File

@@ -18,67 +18,323 @@ PAGES.materiales = {
var field_cantidad_min = safeuuid();
var field_ubicacion = safeuuid();
var field_notas = safeuuid();
var mov_tipo = safeuuid();
var mov_cantidad = safeuuid();
var mov_nota = safeuuid();
var mov_btn = safeuuid();
var mov_registro = safeuuid();
var mov_chart = safeuuid();
var mov_chart_canvas = safeuuid();
var mov_open_modal_btn = safeuuid();
var btn_print_chart = safeuuid();
var mov_modal = safeuuid();
var mov_modal_close = safeuuid();
var btn_guardar = safeuuid();
var btn_borrar = safeuuid();
var FECHA_ISO = new Date().toISOString().split('T')[0];
var movimientos = [];
var movimientosChartInstance = null;
function parseNum(v, fallback = 0) {
var n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function buildMaterialData() {
return {
Nombre: document.getElementById(field_nombre).value,
Unidad: document.getElementById(field_unidad).value,
Cantidad: document.getElementById(field_cantidad).value,
Cantidad_Minima: document.getElementById(field_cantidad_min).value,
Ubicacion: document.getElementById(field_ubicacion).value,
Revision: document.getElementById(field_revision).value,
Notas: document.getElementById(field_notas).value,
Movimientos: movimientos,
};
}
function renderMovimientos() {
var el = document.getElementById(mov_registro);
if (!el) return;
if (!movimientos.length) {
el.innerHTML = '<small>Sin movimientos registrados.</small>';
return;
}
var rows = movimientos
.map((mov) => {
var fecha = mov.Fecha ? new Date(mov.Fecha).toLocaleString('es-ES') : '-';
return html`<tr>
<td>${fecha}</td>
<td>${mov.Tipo || '-'}</td>
<td>${mov.Cantidad ?? '-'}</td>
<td>${mov.Antes ?? '-'}</td>
<td>${mov.Despues ?? '-'}</td>
<td>${mov.Nota || ''}</td>
</tr>`;
})
.join('');
el.innerHTML = html`
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Cantidad</th>
<th>Antes</th>
<th>Después</th>
<th>Nota</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
function renderMovimientosChart() {
var el = document.getElementById(mov_chart);
if (!el) return;
if (movimientosChartInstance) {
movimientosChartInstance.destroy();
movimientosChartInstance = null;
}
if (!movimientos.length) {
el.innerHTML = html`
<h3 style="margin: 0 0 8px 0;">Historial de movimientos por fecha</h3>
<small>Sin datos para graficar.</small>
`;
return;
}
var ordered = [...movimientos].sort((a, b) => {
return new Date(a.Fecha || 0).getTime() - new Date(b.Fecha || 0).getTime();
});
var deltas = [];
var labelsShort = [];
ordered.forEach((mov) => {
var cantidad = parseNum(mov.Cantidad, 0);
var delta = 0;
if (mov.Tipo === 'Entrada') {
delta = cantidad;
} else if (mov.Tipo === 'Salida') {
delta = -cantidad;
} else {
var antes = parseNum(mov.Antes, 0);
var despues = parseNum(mov.Despues, antes);
delta = despues - antes;
}
deltas.push(Number(delta.toFixed(2)));
var fechaTxt = mov.Fecha ? new Date(mov.Fecha).toLocaleString('es-ES') : '-';
labelsShort.push(fechaTxt);
});
var currentStock = parseNum(document.getElementById(field_cantidad)?.value, 0);
var totalNeto = deltas.reduce((acc, n) => acc + n, 0);
var stockInicialInferido = currentStock - totalNeto;
if (ordered.length > 0 && Number.isFinite(parseNum(ordered[0].Antes, NaN))) {
stockInicialInferido = parseNum(ordered[0].Antes, stockInicialInferido);
}
var acumulado = stockInicialInferido;
var values = deltas.map((neto) => {
acumulado += neto;
return Number(acumulado.toFixed(2));
});
el.innerHTML = html`
<h3 style="margin: 0 0 8px 0;">Historial de movimientos por fecha</h3>
<small style="display: block;margin-bottom: 6px;">Stock por fecha (cierre diario)</small>
<canvas id="${mov_chart_canvas}" style="width: 100%;height: 280px;"></canvas>
`;
if (typeof Chart === 'undefined') {
el.innerHTML += '<small>No se pudo cargar la librería de gráficos.</small>';
return;
}
var chartCanvasEl = document.getElementById(mov_chart_canvas);
if (!chartCanvasEl) return;
movimientosChartInstance = new Chart(chartCanvasEl, {
type: 'line',
data: {
labels: labelsShort,
datasets: [
{
label: 'Stock diario',
data: values,
borderColor: '#2d7ef7',
backgroundColor: 'rgba(45,126,247,0.16)',
fill: true,
tension: 0.25,
pointRadius: 3,
pointHoverRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
display: false,
},
y: {
title: {
display: false,
text: 'Stock',
},
grace: '10%',
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
},
});
}
container.innerHTML = html`
<h1>Material <code id="${nameh1}"></code></h1>
${BuildQR('materiales,' + mid)}
<fieldset>
<label>
Fecha Revisión<br />
<input type="date" id="${field_revision}" />
<a
onclick='document.getElementById("${field_revision}").value = "${FECHA_ISO}";'
style="color: blue;cursor: pointer;font-size: 0.9em;"
>Hoy - Contado todas las existencias </a
><br /><br />
</label>
<label>
Nombre<br />
<input type="text" id="${field_nombre}" /><br /><br />
</label>
<label>
Unidad<br />
<select id="${field_unidad}">
<option value="unidad(es)">unidad(es)</option>
<option value="paquete(s)">paquete(s)</option>
<option value="caja(s)">caja(s)</option>
<option value="rollo(s)">rollo(s)</option>
<option value="bote(s)">bote(s)</option>
<fieldset style="width: 100%;max-width: 980px;box-sizing: border-box;">
<div style="display: flex;flex-wrap: wrap;gap: 10px 16px;align-items: flex-end;">
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 220px;flex: 1 1 280px;">
<label for="${field_revision}">Fecha Revisión</label>
<input type="date" id="${field_revision}" style="flex: 1;" />
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 220px;flex: 1 1 280px;">
<label for="${field_nombre}">Nombre</label>
<input type="text" id="${field_nombre}" style="flex: 1;" />
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 220px;flex: 1 1 280px;">
<label for="${field_ubicacion}">Ubicación</label>
<input
type="text"
id="${field_ubicacion}"
value="-"
list="${field_ubicacion}_list"
style="flex: 1;"
/>
<datalist id="${field_ubicacion}_list"></datalist>
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 220px;flex: 1 1 280px;">
<label for="${field_unidad}">Unidad</label>
<select id="${field_unidad}" style="flex: 1;">
<option value="unidad(es)">unidad(es)</option>
<option value="paquete(s)">paquete(s)</option>
<option value="caja(s)">caja(s)</option>
<option value="rollo(s)">rollo(s)</option>
<option value="bote(s)">bote(s)</option>
<option value="metro(s)">metro(s)</option>
<option value="litro(s)">litro(s)</option>
<option value="kg">kg</option>
</select>
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 220px;flex: 1 1 280px;">
<label for="${field_cantidad}">Cantidad Actual</label>
<input type="number" step="0.5" id="${field_cantidad}" style="flex: 1;" disabled />
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 220px;flex: 1 1 280px;">
<label for="${field_cantidad_min}">Cantidad Minima</label>
<input type="number" step="0.5" id="${field_cantidad_min}" style="flex: 1;" />
</div>
<label style="display: flex;flex-direction: column;gap: 6px;min-width: 220px;flex: 1 1 100%;">
Notas
<textarea id="${field_notas}"></textarea>
</label>
</div>
<div
id="${mov_modal}"
style="display: none;position: fixed;z-index: 9999;left: 0;top: 0;width: 100%;height: 100%;overflow: auto;background: rgba(0,0,0,0.45);"
>
<div
style="background: #fff;margin: 2vh auto;padding: 14px;border: 1px solid #888;width: min(960px, 96vw);max-height: 94vh;overflow: auto;border-radius: 8px;box-sizing: border-box;"
>
<div style="display: flex;justify-content: space-between;align-items: center;gap: 10px;">
<h3 style="margin: 0;">Realizar movimiento</h3>
<button type="button" id="${mov_modal_close}" class="rojo">Cerrar</button>
</div>
<div style="margin-top: 12px;display: flex;gap: 10px;align-items: flex-end;flex-wrap: wrap;">
<div style="display: flex;flex-wrap: wrap;gap: 10px 12px;align-items: flex-end;flex: 1 1 420px;">
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 180px;flex: 1 1 220px;">
<label for="${mov_tipo}">Tipo</label>
<select id="${mov_tipo}" style="flex: 1;">
<option value="Entrada">Entrada</option>
<option value="Salida">Salida</option>
<option value="Ajuste">Ajuste</option>
</select>
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 180px;flex: 1 1 220px;">
<label for="${mov_cantidad}">Cantidad</label>
<input type="number" step="0.5" id="${mov_cantidad}" style="flex: 1;" />
</div>
<div style="display: flex;flex-direction: column;align-items: stretch;gap: 6px;min-width: 180px;flex: 1 1 220px;">
<label for="${mov_nota}">Nota</label>
<input type="text" id="${mov_nota}" style="flex: 1;" placeholder="Motivo del movimiento" />
</div>
</div>
<div style="display: flex;justify-content: flex-end;flex: 1 1 120px;min-width: 120px;">
<button type="button" class="saveico" id="${mov_btn}">
<img src="static/floppy_disk_green.png" />
<br>Guardar
</button>
</div>
</div>
<h4 style="margin: 14px 0 6px 0;">Registro de movimientos</h4>
<div id="${mov_registro}"></div>
</div>
</div>
<option value="metro(s)">metro(s)</option>
<option value="litro(s)">litro(s)</option>
<option value="kg">kg</option></select
><br /><br />
</label>
<label>
Cantidad Actual<br />
<input type="number" step="0.5" id="${field_cantidad}" /><br /><br />
</label>
<label>
Cantidad Minima<br />
<input type="number" step="0.5" id="${field_cantidad_min}" /><br /><br />
</label>
<label>
Ubicación<br />
<input
type="text"
id="${field_ubicacion}"
value="-"
list="${field_ubicacion}_list"
/><br /><br />
<!-- Autocompletar con ubicaciones existentes -->
<datalist id="${field_ubicacion}_list"></datalist>
</label>
<label>
Notas<br />
<textarea id="${field_notas}"></textarea><br /><br />
</label>
<hr />
<button class="btn5" id="${btn_guardar}">Guardar</button>
<button class="rojo" id="${btn_borrar}">Borrar</button>
<button class="saveico" id="${btn_guardar}">
<img src="static/floppy_disk_green.png" />
<br>Guardar
</button>
<button class="delico" id="${btn_borrar}">
<img src="static/garbage.png" />
<br>Borrar
</button>
<button class="opicon" id="${mov_open_modal_btn}">
<img src="static/exchange.png" />
<br>Movimientos
</button>
<button class="opicon" onclick="setUrlHash('materiales')" style="float: right;"> <!-- Align to the right -->
<img src="static/exit.png" />
<br>Salir
</button>
<button class="opicon" onclick="window.print()" style="float: right;"> <!-- Align to the right -->
<img src="static/printer2.png" />
<br>Imprimir
</button>
</fieldset>
<div id="${mov_chart}" style="max-width: 980px;width: 100%;margin-top: 14px;min-height: 260px;height: min(400px, 52vh);"></div>
`;
// Cargar ubicaciones existentes para autocompletar
DB.map('materiales', (data) => {
@@ -125,6 +381,9 @@ PAGES.materiales = {
document.getElementById(field_ubicacion).value = data['Ubicacion'] || '-';
document.getElementById(field_revision).value = data['Revision'] || '-';
document.getElementById(field_notas).value = data['Notas'] || '';
movimientos = Array.isArray(data['Movimientos']) ? data['Movimientos'] : [];
renderMovimientos();
renderMovimientosChart();
}
if (typeof data == 'string') {
TS_decrypt(
@@ -140,6 +399,79 @@ PAGES.materiales = {
load_data(data || {});
}
});
document.getElementById(mov_open_modal_btn).onclick = () => {
document.getElementById(mov_modal).style.display = 'block';
renderMovimientos();
};
document.getElementById(mov_modal_close).onclick = () => {
document.getElementById(mov_modal).style.display = 'none';
};
document.getElementById(mov_modal).onclick = (evt) => {
if (evt.target.id === mov_modal) {
document.getElementById(mov_modal).style.display = 'none';
}
};
document.getElementById(mov_btn).onclick = () => {
var btn = document.getElementById(mov_btn);
if (btn.disabled) return;
var tipo = document.getElementById(mov_tipo).value;
var cantidadMov = parseNum(document.getElementById(mov_cantidad).value, NaN);
var nota = document.getElementById(mov_nota).value || '';
var actual = parseNum(document.getElementById(field_cantidad).value, 0);
if (!Number.isFinite(cantidadMov) || cantidadMov <= 0) {
toastr.warning('Indica una cantidad válida para el movimiento');
return;
}
var nuevaCantidad = actual;
if (tipo === 'Entrada') {
nuevaCantidad = actual + cantidadMov;
} else if (tipo === 'Salida') {
nuevaCantidad = actual - cantidadMov;
} else if (tipo === 'Ajuste') {
nuevaCantidad = cantidadMov;
}
movimientos.unshift({
Fecha: new Date().toISOString(),
Tipo: tipo,
Cantidad: cantidadMov,
Antes: actual,
Despues: nuevaCantidad,
Nota: nota,
});
document.getElementById(field_cantidad).value = nuevaCantidad;
document.getElementById(field_revision).value = FECHA_ISO;
document.getElementById(mov_cantidad).value = '';
document.getElementById(mov_nota).value = '';
renderMovimientos();
renderMovimientosChart();
btn.disabled = true;
btn.style.opacity = '0.5';
document.getElementById('actionStatus').style.display = 'block';
DB.put('materiales', mid, buildMaterialData())
.then(() => {
toastr.success('Movimiento registrado');
})
.catch((e) => {
console.warn('DB.put error', e);
toastr.error('Error al guardar el movimiento');
})
.finally(() => {
btn.disabled = false;
btn.style.opacity = '1';
document.getElementById('actionStatus').style.display = 'none';
});
};
document.getElementById(btn_guardar).onclick = () => {
// Disable button to prevent double-clicking
var guardarBtn = document.getElementById(btn_guardar);
@@ -148,15 +480,7 @@ PAGES.materiales = {
guardarBtn.disabled = true;
guardarBtn.style.opacity = '0.5';
var data = {
Nombre: document.getElementById(field_nombre).value,
Unidad: document.getElementById(field_unidad).value,
Cantidad: document.getElementById(field_cantidad).value,
Cantidad_Minima: document.getElementById(field_cantidad_min).value,
Ubicacion: document.getElementById(field_ubicacion).value,
Revision: document.getElementById(field_revision).value,
Notas: document.getElementById(field_notas).value,
};
var data = buildMaterialData();
document.getElementById('actionStatus').style.display = 'block';
DB.put('materiales', mid, data)
.then(() => {

294
src/page/mensajes.js Normal file
View File

@@ -0,0 +1,294 @@
PERMS['mensajes'] = 'Mensajes';
PERMS['mensajes:edit'] = '&gt; Editar';
PAGES.mensajes = {
navcss: 'btn5',
icon: 'static/appico/message.png',
AccessControl: true,
// AccessControlRole is not needed.
Title: 'Mensajes',
edit: function (mid) {
if (!checkRole('mensajes:edit')) {
setUrlHash('mensajes');
return;
}
var nameh1 = safeuuid();
var field_asunto = safeuuid();
var field_contenido = safeuuid();
var field_autor = safeuuid();
var field_files = safeuuid();
var attachments_list = safeuuid();
var btn_guardar = safeuuid();
var btn_borrar = safeuuid();
var div_actions = safeuuid();
container.innerHTML = html`
<h1>Mensaje <code id="${nameh1}"></code></h1>
<fieldset style="float: none; width: calc(100% - 40px);max-width: none;">
<legend>Valores</legend>
<div style="max-width: 400px;">
<label>
Asunto<br />
<input type="text" id="${field_asunto}" value="" /><br /><br />
</label>
<label>
Origen<br />
<input type="text" id="${field_autor}" value="" /><br /><br />
</label>
</div>
<label>
Contenido<br />
<textarea
id="${field_contenido}"
style="width: calc(100% - 15px); height: 400px;"
></textarea
><br /><br />
</label>
<label>
Adjuntos (Fotos o archivos)<br />
<input type="file" id="${field_files}" multiple /><br /><br />
<div id="${attachments_list}"></div>
</label>
<hr />
<button class="saveico" id="${btn_guardar}">
<img src="static/floppy_disk_green.png" />
<br>Guardar
</button>
<button class="delico" id="${btn_borrar}">
<img src="static/garbage.png" />
<br>Borrar
</button>
<button class="opicon" onclick="setUrlHash('mensajes')" style="float: right;"> <!-- Align to the right -->
<img src="static/exit.png" />
<br>Salir
</button>
<button class="opicon" onclick="window.print()" style="float: right;"> <!-- Align to the right -->
<img src="static/printer2.png" />
<br>Imprimir
</button>
</fieldset>
`;
DB.get('mensajes', mid).then((data) => {
function load_data(data, ENC = '') {
document.getElementById(nameh1).innerText = mid;
document.getElementById(field_asunto).value = data['Asunto'] || '';
document.getElementById(field_contenido).value = data['Contenido'] || '';
document.getElementById(field_autor).value = data['Autor'] || SUB_LOGGED_IN_DETAILS["Nombre"] || '';
// Mostrar adjuntos existentes (si los hay).
// No confiar en `data._attachments` porque `DB.get` devuelve solo `doc.data`.
const attachContainer = document.getElementById(attachments_list);
attachContainer.innerHTML = '';
// Usar API de DB para listar attachments (no acceder a internals desde la UI)
DB.listAttachments('mensajes', mid)
.then((list) => {
if (!list || !Array.isArray(list)) return;
list.forEach((att) => {
addAttachmentRow(att.name, att.dataUrl);
});
})
.catch((e) => {
console.warn('listAttachments error', e);
});
}
if (typeof data == 'string') {
TS_decrypt(data, SECRET, (data) => {
load_data(data, '%E');
});
} else {
load_data(data || {});
}
});
// gestión de archivos seleccionados antes de guardar
const attachmentsToUpload = [];
function addAttachmentRow(name, url) {
const attachContainer = document.getElementById(attachments_list);
const idRow = safeuuid();
const isImage = url && url.indexOf('data:image') === 0;
const preview = isImage
? `<img src="${url}" height="80" style="margin-right:8px;">`
: `<a href="${url}" target="_blank">${name}</a>`;
const html = `
<div id="${idRow}" style="display:flex;align-items:center;margin:6px 0;border:1px solid #ddd;padding:6px;border-radius:6px;">
<div style="flex:1">${preview}<strong style="margin-left:8px">${name}</strong></div>
<div><button type="button" class="rojo" data-name="${name}">Borrar</button></div>
</div>`;
attachContainer.insertAdjacentHTML('beforeend', html);
attachContainer.querySelectorAll(`button[data-name="${name}"]`).forEach((btn) => {
btn.onclick = () => {
if (!confirm('¿Borrar este adjunto?')) return;
// Usar API pública en DB para borrar metadata del attachment
DB.deleteAttachment('mensajes', mid, name)
.then((ok) => {
if (ok) {
document.getElementById(idRow).remove();
toastr.error('Adjunto borrado');
} else {
toastr.error('No se pudo borrar el adjunto');
}
})
.catch((e) => {
console.warn('deleteAttachment error', e);
toastr.error('Error borrando adjunto');
});
};
});
}
document.getElementById(field_files).addEventListener('change', function (e) {
const files = Array.from(e.target.files || []);
files.forEach((file) => {
const reader = new FileReader();
reader.onload = function (ev) {
const dataUrl = ev.target.result;
attachmentsToUpload.push({
name: file.name,
data: dataUrl,
type: file.type || 'application/octet-stream',
});
// mostrar preview temporal
addAttachmentRow(file.name, dataUrl);
};
reader.readAsDataURL(file);
});
// limpiar input para permitir re-subidas del mismo archivo
e.target.value = '';
});
document.getElementById(btn_guardar).onclick = () => {
// Disable button to prevent double-clicking
var guardarBtn = document.getElementById(btn_guardar);
if (guardarBtn.disabled) return;
guardarBtn.disabled = true;
guardarBtn.style.opacity = '0.5';
var data = {
Autor: document.getElementById(field_autor).value,
Contenido: document.getElementById(field_contenido).value,
Asunto: document.getElementById(field_asunto).value,
};
document.getElementById('actionStatus').style.display = 'block';
DB.put('mensajes', mid, data)
.then(() => {
// subir attachments si los hay
const uploadPromises = [];
attachmentsToUpload.forEach((att) => {
if (DB.putAttachment) {
uploadPromises.push(
DB.putAttachment('mensajes', mid, att.name, att.data, att.type).catch((e) => {
console.warn('putAttachment error', e);
})
);
}
});
Promise.all(uploadPromises)
.then(() => {
// limpiar lista temporal y recargar attachments
attachmentsToUpload.length = 0;
try {
// recargar lista actual sin salir
const pouchId = 'mensajes:' + mid;
if (DB && DB._internal && DB._internal.local) {
DB._internal.local
.get(pouchId, { attachments: true })
.then((doc) => {
const attachContainer = document.getElementById(attachments_list);
attachContainer.innerHTML = '';
if (doc && doc._attachments) {
Object.keys(doc._attachments).forEach((name) => {
try {
const att = doc._attachments[name];
if (att && att.data) {
const durl =
'data:' +
(att.content_type || 'application/octet-stream') +
';base64,' +
att.data;
addAttachmentRow(name, durl);
return;
}
} catch (e) {}
DB.getAttachment('mensajes', mid, name)
.then((durl) => {
addAttachmentRow(name, durl);
})
.catch(() => {});
});
}
})
.catch(() => {
/* ignore reload errors */
});
}
} catch (e) {}
toastr.success('Guardado!');
setTimeout(() => {
document.getElementById('actionStatus').style.display = 'none';
setUrlHash('mensajes');
}, SAVE_WAIT);
})
.catch((e) => {
console.warn('Attachment upload error', e);
document.getElementById('actionStatus').style.display = 'none';
guardarBtn.disabled = false;
guardarBtn.style.opacity = '1';
toastr.error('Error al guardar los adjuntos');
});
})
.catch((e) => {
console.warn('DB.put error', e);
document.getElementById('actionStatus').style.display = 'none';
guardarBtn.disabled = false;
guardarBtn.style.opacity = '1';
toastr.error('Error al guardar el mensaje');
});
};
document.getElementById(btn_borrar).onclick = () => {
if (confirm('¿Quieres borrar este mensaje?') == true) {
DB.del('mensajes', mid).then(() => {
toastr.error('Borrado!');
setTimeout(() => {
setUrlHash('mensajes');
}, SAVE_WAIT);
});
}
};
},
index: function () {
if (!checkRole('mensajes')) {
setUrlHash('index');
return;
}
const tablebody = safeuuid();
var btn_new = safeuuid();
container.innerHTML = html`
<h1>Mensajes</h1>
<button id="${btn_new}">Nuevo mensaje</button>
<div id="cont"></div>
`;
TS_IndexElement(
'mensajes',
[
{
key: 'Autor',
type: 'raw',
default: '',
label: 'Origen',
},
{
key: 'Asunto',
type: 'raw',
default: '',
label: 'Asunto',
},
],
'mensajes',
document.querySelector('#cont')
);
if (!checkRole('mensajes:edit')) {
document.getElementById(btn_new).style.display = 'none';
} else {
document.getElementById(btn_new).onclick = () => {
setUrlHash('mensajes,' + safeuuid(''));
};
}
},
};

View File

@@ -45,8 +45,22 @@ PAGES.notas = {
<div id="${attachments_list}"></div>
</label>
<hr />
<button class="btn5" id="${btn_guardar}">Guardar</button>
<button class="rojo" id="${btn_borrar}">Borrar</button>
<button class="saveico" id="${btn_guardar}">
<img src="static/floppy_disk_green.png" />
<br>Guardar
</button>
<button class="delico" id="${btn_borrar}">
<img src="static/garbage.png" />
<br>Borrar
</button>
<button class="opicon" onclick="setUrlHash('notas')" style="float: right;"> <!-- Align to the right -->
<img src="static/exit.png" />
<br>Salir
</button>
<button class="opicon" onclick="window.print()" style="float: right;"> <!-- Align to the right -->
<img src="static/printer2.png" />
<br>Imprimir
</button>
</fieldset>
`;
var divact = document.getElementById(div_actions);

View File

@@ -6,6 +6,12 @@ PAGES.pagos = {
AccessControl: true,
Title: 'Pagos',
__getVisiblePersonas: function () {
return Object.fromEntries(
Object.entries(SC_Personas).filter(([_, persona]) => !(persona && persona.Oculto === true))
);
},
// Datafono view for creating/processing transactions
datafono: function (prefilledData = {}) {
if (!checkRole('pagos:edit')) {
@@ -413,9 +419,10 @@ PAGES.pagos = {
var container = document.querySelector('#personaSelector');
container.innerHTML = '';
document.getElementById(field_persona).value = selectedPersona;
var visiblePersonas = PAGES.pagos.__getVisiblePersonas();
addCategory_Personas(
container,
SC_Personas,
visiblePersonas,
selectedPersona,
(personaId) => {
document.getElementById(field_persona).value = personaId;
@@ -423,7 +430,8 @@ PAGES.pagos = {
},
'Monedero',
false,
'- No hay personas registradas -'
'- No hay personas registradas -',
true
);
}
@@ -431,9 +439,10 @@ PAGES.pagos = {
var container = document.querySelector('#personaDestinoSelector');
container.innerHTML = '';
document.getElementById(field_persona_destino).value = selectedPersonaDestino;
var visiblePersonas = PAGES.pagos.__getVisiblePersonas();
addCategory_Personas(
container,
SC_Personas,
visiblePersonas,
selectedPersonaDestino,
(personaId) => {
document.getElementById(field_persona_destino).value = personaId;
@@ -441,7 +450,8 @@ PAGES.pagos = {
},
'Monedero Destino',
false,
'- No hay personas registradas -'
'- No hay personas registradas -',
true
);
}
@@ -498,20 +508,24 @@ PAGES.pagos = {
// Don't update balance for Efectivo Gastos (paying with cash)
var shouldUpdateBalance = !(tipo === 'Gasto' && metodo === 'Efectivo');
function finalizeTransactionSave() {
saveTransaction(ticketId, transactionData);
}
if (shouldUpdateBalance) {
updateWalletBalance(personaId, tipo, monto, () => {
if (tipo === 'Transferencia') {
var destinoId = transactionData.PersonaDestino;
updateWalletBalance(destinoId, 'Ingreso', monto, () => {
saveTransaction(ticketId, transactionData);
finalizeTransactionSave();
});
} else {
saveTransaction(ticketId, transactionData);
finalizeTransactionSave();
}
});
} else {
// Skip balance update for Efectivo Gastos
saveTransaction(ticketId, transactionData);
finalizeTransactionSave();
}
}
@@ -660,12 +674,13 @@ PAGES.pagos = {
},
// Edit/view transaction
edit: function (tid) {
edit: function (ftid) {
if (!checkRole('pagos')) {
setUrlHash('pagos');
return;
}
var tid2 = location.hash.split(',');
var tid = ftid.split(',')[0];
var tid2 = location.hash.split("?")[0].split(',');
if (tid == 'datafono') {
PAGES.pagos.datafono();
return;
@@ -1157,10 +1172,8 @@ PAGES.pagos = {
totalData.ingresos[id] = monto;
}
} else if (tipo === 'Gasto') {
if (metodo != 'Tarjeta') {
totalData.ingresos[id] = 0;
totalData.gastos[id] = monto;
}
totalData.ingresos[id] = 0;
totalData.gastos[id] = monto;
} else {
// For Transferencias, don't count in totals
totalData.ingresos[id] = 0;
@@ -1441,9 +1454,10 @@ PAGES.pagos = {
var container = document.querySelector('#personaSelector');
container.innerHTML = '';
document.getElementById(field_persona).value = selectedPersona;
var visiblePersonas = PAGES.pagos.__getVisiblePersonas();
addCategory_Personas(
container,
SC_Personas,
visiblePersonas,
selectedPersona,
(personaId) => {
document.getElementById(field_persona).value = personaId;
@@ -1451,7 +1465,8 @@ PAGES.pagos = {
},
'Monedero',
false,
'- No hay personas registradas -'
'- No hay personas registradas -',
true
);
}
@@ -1459,9 +1474,10 @@ PAGES.pagos = {
var container = document.querySelector('#personaDestinoSelector');
container.innerHTML = '';
document.getElementById(field_persona_destino).value = selectedPersonaDestino;
var visiblePersonas = PAGES.pagos.__getVisiblePersonas();
addCategory_Personas(
container,
SC_Personas,
visiblePersonas,
selectedPersonaDestino,
(personaId) => {
document.getElementById(field_persona_destino).value = personaId;
@@ -1469,7 +1485,8 @@ PAGES.pagos = {
},
'Monedero Destino',
false,
'- No hay personas registradas -'
'- No hay personas registradas -',
true
);
}

427
src/page/panel.js Normal file
View File

@@ -0,0 +1,427 @@
PERMS['panel'] = 'Panel';
PAGES.panel = {
navcss: 'btn2',
icon: 'static/appico/calendar.png',
AccessControl: true,
Title: 'Panel',
index: function () {
if (!checkRole('panel')) {
setUrlHash('index');
return;
}
var contentId = safeuuid();
container.innerHTML = html`
<h1>Panel de acogida del día</h1>
<p>Quiz de aprendizaje con retroalimentación para empezar la jornada.</p>
<div id="${contentId}">Cargando datos del día...</div>
`;
PAGES.panel
.__buildDailyContext()
.then((ctx) => {
var questions = PAGES.panel.__buildQuestions(ctx);
PAGES.panel.__renderQuiz(contentId, ctx, questions);
})
.catch((e) => {
console.warn('Panel load error', e);
document.getElementById(contentId).innerHTML =
'<b>No se pudo cargar el Panel ahora mismo.</b>';
});
},
__decryptIfNeeded: function (table, id, raw) {
return new Promise((resolve) => {
if (typeof raw !== 'string') {
resolve(raw || {});
return;
}
TS_decrypt(
raw,
SECRET,
(data) => {
resolve(data || {});
},
table,
id
);
});
},
__getTodayComedor: async function () {
var rows = await DB.list('comedor');
var today = CurrentISODate();
var items = [];
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var data = await PAGES.panel.__decryptIfNeeded('comedor', row.id, row.data);
if ((data.Fecha || '') === today) {
items.push(data);
}
}
if (items.length === 0) {
return {
Primero: '',
Segundo: '',
Postre: '',
Tipo: '',
};
}
items.sort((a, b) => {
var ta = (a.Tipo || '').toLowerCase();
var tb = (b.Tipo || '').toLowerCase();
return ta < tb ? -1 : 1;
});
return items[0] || {};
},
__getNotaById: async function (id) {
var data = await DB.get('notas', id);
if (!data) return {};
return await PAGES.panel.__decryptIfNeeded('notas', id, data);
},
__getDiarioHoy: async function () {
var did = 'diario-' + CurrentISODate();
var data = await DB.get('aulas_informes', did);
if (!data) return {};
return await PAGES.panel.__decryptIfNeeded('aulas_informes', did, data);
},
__extractFirstLine: function (text) {
var lines = String(text || '')
.split('\n')
.map((x) => x.trim())
.filter((x) => x !== '');
return lines[0] || '';
},
__buildDailyContext: async function () {
var comedor = await PAGES.panel.__getTodayComedor();
var tareas = await PAGES.panel.__getNotaById('tareas');
var diario = await PAGES.panel.__getDiarioHoy();
var planHoy =
PAGES.panel.__extractFirstLine(tareas.Contenido) ||
PAGES.panel.__extractFirstLine(diario.Contenido) ||
'Revisar rutinas, colaborar y participar en las actividades del aula.';
return {
fecha: CurrentISODate(),
comedor: {
primero: (comedor.Primero || '').trim(),
primeroPicto: comedor.Primero_Picto || '',
segundo: (comedor.Segundo || '').trim(),
segundoPicto: comedor.Segundo_Picto || '',
postre: (comedor.Postre || '').trim(),
postrePicto: comedor.Postre_Picto || '',
tipo: (comedor.Tipo || '').trim(),
},
planHoy: planHoy,
};
},
__hasPictoData: function (picto) {
return !!(
picto &&
typeof picto === 'object' &&
((picto.text || '').trim() !== '' || (picto.arasaacId || '').trim() !== '')
);
},
__getOptionPicto: function (question, option) {
if (!question || !question.optionPictos) return '';
return question.optionPictos[String(option || '').trim()] || '';
},
__renderOptionContent: function (question, option) {
var picto = PAGES.panel.__getOptionPicto(question, option);
var withPicto = typeof makePictoStatic === 'function' && PAGES.panel.__hasPictoData(picto);
if (!withPicto) return `<span>${option}</span>`;
return `
<span style="align-items:center; gap:8px;">
${makePictoStatic(picto)}
<span>${option}</span>
</span>
`;
},
__pickDistractors: function (correct, pool, count) {
var options = [];
var seen = {};
var cleanCorrect = (correct || '').trim();
pool.forEach((item) => {
var text = String(item || '').trim();
if (text === '' || text === cleanCorrect || seen[text]) return;
seen[text] = true;
options.push(text);
});
var out = [];
for (var i = 0; i < options.length && out.length < count; i++) {
out.push(options[i]);
}
while (out.length < count) {
out.push('No aplica hoy');
}
return out;
},
__shuffle: function (arr) {
var copy = arr.slice();
for (var i = copy.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = copy[i];
copy[i] = copy[j];
copy[j] = tmp;
}
return copy;
},
__buildQuestions: function (ctx) {
var c = ctx.comedor || {};
var poolComedor = [c.primero, c.segundo, c.postre, 'No hay menú registrado'];
var comedorPictosByText = {};
if (c.primero) comedorPictosByText[c.primero] = c.primeroPicto || '';
if (c.segundo) comedorPictosByText[c.segundo] = c.segundoPicto || '';
if (c.postre) comedorPictosByText[c.postre] = c.postrePicto || '';
var questions = [];
if (c.primero) {
var opts1 = [c.primero].concat(PAGES.panel.__pickDistractors(c.primero, poolComedor, 3));
questions.push({
id: 'q-comida-primero',
text: '¿Qué hay de comer hoy de primero?',
options: PAGES.panel.__shuffle(opts1),
optionPictos: comedorPictosByText,
correct: c.primero,
ok: '¡Correcto! Ya sabes el primer plato de hoy.',
bad: 'Repasa el menú del día para anticipar la comida.',
});
}
if (c.segundo) {
var opts2 = [c.segundo].concat(PAGES.panel.__pickDistractors(c.segundo, poolComedor, 3));
questions.push({
id: 'q-comida-segundo',
text: '¿Y de segundo, qué toca?',
options: PAGES.panel.__shuffle(opts2),
optionPictos: comedorPictosByText,
correct: c.segundo,
ok: '¡Bien! Segundo identificado.',
bad: 'Casi. Mira el módulo Comedor para recordar el segundo plato.',
});
}
if (c.postre) {
var opts3 = [c.postre].concat(PAGES.panel.__pickDistractors(c.postre, poolComedor, 3));
questions.push({
id: 'q-comida-postre',
text: '¿Cuál es el postre de hoy?',
options: PAGES.panel.__shuffle(opts3),
optionPictos: comedorPictosByText,
correct: c.postre,
ok: '¡Perfecto! Postre acertado.',
bad: 'No pasa nada, revisa el postre en el menú diario.',
});
}
var plan = ctx.planHoy || '';
var distractPlan = [
'No hay actividades planificadas hoy',
'Solo descanso todo el día',
'Actividad libre sin objetivos',
];
var planOptions = [plan].concat(PAGES.panel.__pickDistractors(plan, distractPlan, 3));
questions.push({
id: 'q-plan-hoy',
text: '¿Qué vamos a hacer hoy?',
options: PAGES.panel.__shuffle(planOptions),
correct: plan,
ok: '¡Muy bien! Tienes claro el plan del día.',
bad: 'Revisa las tareas/diario para conocer el plan del día.',
});
if (questions.length === 0) {
questions.push({
id: 'q-fallback',
text: 'No hay menú cargado. ¿Qué acción es correcta ahora?',
options: [
'Consultar el módulo Comedor y las Notas del día',
'Ignorar la planificación diaria',
'Esperar sin revisar información',
'Saltar la acogida',
],
correct: 'Consultar el módulo Comedor y las Notas del día',
ok: 'Correcto. Ese es el siguiente paso recomendado.',
bad: 'La acogida mejora si revisamos menú y planificación diaria.',
});
}
return questions;
},
__renderQuiz: function (contentId, ctx, questions) {
var target = document.getElementById(contentId);
var state = {
idx: 0,
answers: {},
score: 0,
feedback: '',
feedbackType: '',
reviewMode: false,
};
function saveResult() {
var rid = CurrentISOTime() + '-' + safeuuid('');
var payload = {
Fecha: ctx.fecha,
Persona: SUB_LOGGED_IN_ID || '',
Aciertos: state.score,
Total: questions.length,
Respuestas: state.answers,
};
DB.put('panel_respuestas', rid, payload);
}
function renderCurrent() {
var q = questions[state.idx];
if (!q) return;
var selected = state.answers[q.id] || '';
var optionsHtml = q.options
.map((option, i) => {
var oid = safeuuid();
var checked = selected === option ? 'checked' : '';
var disabled = state.reviewMode ? 'disabled' : '';
var optionStyle =
'display:block;margin: 8px 0;padding: 8px;border: 1px solid #ccc;border-radius: 6px;cursor:pointer;max-width: 150px;text-align: center;';
if (state.reviewMode) {
if (option === q.correct) {
optionStyle =
'display:block;margin: 8px 0;padding: 8px;border: 2px solid #2ed573;background:#eafff1;border-radius: 6px;cursor:pointer;max-width: 150px;text-align: center;';
} else if (option === selected && option !== q.correct) {
optionStyle =
'display:block;margin: 8px 0;padding: 8px;border: 2px solid #ff4757;background:#ffecec;border-radius: 6px;cursor:pointer;max-width: 150px;text-align: center;';
}
}
var optionContent = PAGES.panel.__renderOptionContent(q, option);
return `
<label class="panel-option" for="${oid}" style="${optionStyle}">
<input id="${oid}" type="radio" name="panel-question" value="${option.replace(/"/g, '&quot;')}" ${checked} ${disabled} />
${optionContent}
</label>
`;
})
.join('');
var feedbackColor = '#555';
if (state.feedbackType === 'ok') feedbackColor = '#1f8f4a';
if (state.feedbackType === 'bad') feedbackColor = '#c0392b';
var nextButtonText = state.reviewMode ? 'Continuar' : 'Comprobar';
target.innerHTML = html`
<fieldset style="max-width: 800px;">
<legend>Pregunta ${state.idx + 1} de ${questions.length}</legend>
<div style="margin-bottom: 10px;"><b>${q.text}</b></div>
<small>
Menú hoy: ${ctx.comedor.primero || '—'} / ${ctx.comedor.segundo || '—'} /
${ctx.comedor.postre || '—'}
</small>
<div style="margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px;">${optionsHtml}</div>
<div id="panel-feedback" style="margin-top: 12px; color:${feedbackColor};"><i>${state.feedback || ''}</i></div>
<div style="margin-top: 12px; display:flex; gap:8px;">
<button class="btn5" id="panel-next">${nextButtonText}</button>
<button id="panel-cancel">Salir</button>
</div>
</fieldset>
`;
document.getElementById('panel-cancel').onclick = () => setUrlHash('index');
document.getElementById('panel-next').onclick = () => {
if (state.reviewMode) {
state.reviewMode = false;
state.feedback = '';
state.feedbackType = '';
if (state.idx < questions.length - 1) {
state.idx++;
renderCurrent();
return;
}
saveResult();
renderFinal();
return;
}
var checked = document.querySelector('input[name="panel-question"]:checked');
if (!checked) {
state.feedback = 'Selecciona una opción antes de continuar.';
state.feedbackType = 'bad';
renderCurrent();
return;
}
var answer = checked.value;
state.answers[q.id] = answer;
var wasCorrect = answer === q.correct;
if (wasCorrect) {
state.score++;
state.feedback = '✅ ' + q.ok;
state.feedbackType = 'ok';
} else {
state.feedback = '❌ ' + q.bad + ' Respuesta esperada: ' + q.correct;
state.feedbackType = 'bad';
}
state.reviewMode = true;
renderCurrent();
};
}
function renderFinal() {
var total = questions.length;
var ratio = total > 0 ? Math.round((state.score / total) * 100) : 0;
var msg = 'Buen trabajo. Sigue reforzando la acogida diaria.';
if (ratio >= 80) msg = 'Excelente acogida: gran comprensión del día.';
else if (ratio >= 50) msg = 'Buen avance. Revisa comedor/tareas para reforzar.';
target.innerHTML = html`
<fieldset style="max-width: 800px;">
<legend>Resultado del Panel</legend>
<h2>${state.score} / ${total} aciertos (${ratio}%)</h2>
<p>${msg}</p>
<p><b>Plan de hoy:</b> ${ctx.planHoy}</p>
<button class="btn5" id="panel-repeat">Repetir quiz</button>
<button id="panel-home">Volver al inicio</button>
</fieldset>
`;
document.getElementById('panel-repeat').onclick = () => {
state.idx = 0;
state.answers = {};
state.score = 0;
state.feedback = '';
state.feedbackType = '';
state.reviewMode = false;
renderCurrent();
};
document.getElementById('panel-home').onclick = () => setUrlHash('index');
}
renderCurrent();
},
};

View File

@@ -17,75 +17,81 @@ PAGES.personas = {
var field_notas = safeuuid();
var field_anilla = safeuuid();
var field_foto = safeuuid();
var field_oculto = safeuuid();
var render_foto = safeuuid();
var field_monedero_balance = safeuuid();
var field_monedero_notas = safeuuid();
var btn_guardar = safeuuid();
var btn_borrar = safeuuid();
var btn_ver_monedero = safeuuid();
container.innerHTML = html`
<h1>Persona <code id="${nameh1}"></code></h1>
${BuildQR('personas,' + mid, 'Esta Persona')}
<fieldset>
<label>
Nombre<br>
<input type="text" id="${field_nombre}"><br><br>
</label>
<label>
Zona<br>
<input type="text" id="${field_zona}"><br><br>
</label>
</label>
<details>
<summary>Permisos</summary>
<form id="${permisosdet}">
</form>
</details>
<label>
Anilla<br>
<input type="color" id="${field_anilla}"><br><br>
</label>
<label>
Foto (PNG o JPG)<br>
<img id="${render_foto}" height="100px" style="border: 3px inset; min-width: 7px;" src="static/ico/user_generic.png">
<input type="file" accept="image/*" id="${field_foto}" style="display: none;"><br><br>
</label>
<details style="background: #e3f2fd; border: 2px solid #2196f3; border-radius: 8px; padding: 10px; margin: 15px 0;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">💳 Tarjeta Monedero</summary>
<div style="padding: 15px;">
<label>
Balance Actual<br>
<input type="number" step="0.01" id="${field_monedero_balance}" style="font-size: 24px; font-weight: bold; color: #1976d2;"><br>
<small>Se actualiza automáticamente con las transacciones</small><br><br>
</label>
<label>
Notas del Monedero<br>
<textarea id="${field_monedero_notas}" rows="3" placeholder="Notas adicionales sobre el monedero..."></textarea><br><br>
</label>
<button type="button" id="${btn_ver_monedero}" class="btn5">Ver Transacciones del Monedero</button>
</div>
</details>
<details style="background: #e3fde3ff; border: 2px solid #21f328ff; border-radius: 8px; padding: 10px; margin: 15px 0; display: none;">
<summary style="cursor: pointer; font-weight: bold; color: rgba(26, 141, 3, 1);">🔗 Generar enlaces</summary>
<div style="padding: 15px;">
<label>
Este servidor<br>
<input type="url" value="${location.protocol}//${location.hostname}:${location.port}${location.pathname}?login=${getDBName()}:${SECRET}&sublogin=${mid}" style="font-size: 10px; font-weight: bold; color: #000;"><br>
</label>
<label>
Cualquier Servidor<br>
<input type="url" value="https://tech.eus/ts/?login=${getDBName()}:${SECRET}&sublogin=${mid}" style="font-size: 10px; font-weight: bold; color: #000;"><br>
</label>
</div>
</details>
<label>
Notas<br>
<textarea id="${field_notas}"></textarea><br><br>
</label><hr>
<button class="btn5" id="${btn_guardar}">Guardar</button>
<button class="rojo" id="${btn_borrar}">Borrar</button>
<fieldset style="width: 100%;max-width: 980px;box-sizing: border-box;">
<div style="display: flex;flex-wrap: wrap;gap: 10px 16px;">
<label style="display: flex;flex-direction: column;gap: 6px;max-width: 105px;flex: 1 1 105px;">
Foto
<img id="${render_foto}" height="100px" style="border: 3px inset; min-width: 7px; width: fit-content;" src="static/ico/user_generic.png">
<input type="file" accept="image/*" id="${field_foto}" style="display: none;">
</label>
<label style="display: flex;flex-direction: column;gap: 6px;min-width: 220px;flex: 1 1 280px;">
Nombre
<input type="text" id="${field_nombre}">
</label>
<label style="display: flex;flex-direction: column;gap: 6px;min-width: 170px;flex: 1 1 170px;">
Zona
<input type="text" id="${field_zona}">
</label>
<label style="display: flex;flex-direction: column;gap: 6px;max-width: 170px;flex: 1 1 170px;">
Saldo Monedero
<input type="number" step="0.01" id="${field_monedero_balance}" disabled style="color: #000; font-weight: bold;">
</label>
<label style="display: flex;flex-direction: column;gap: 6px;max-width: 50px;flex: 1 1 50px;">
Anilla
<input type="color" id="${field_anilla}">
</label>
<label style="display: flex;flex-direction: column;gap: 6px;max-width: 60px;flex: 1 1 60px;">
Ocultar?
<input type="checkbox" id="${field_oculto}" style="height: 50px; width: 50px; margin: 0;">
</label>
<label style="display: flex;flex-direction: column;gap: 6px;min-width: 220px;flex: 1 1 100%;">
Notas
<textarea id="${field_notas}"></textarea>
</label>
<details style="flex: 1 1 100%;">
<summary>Permisos</summary>
<form id="${permisosdet}">
</form>
</details>
<details style="background: #e3fde3ff; border: 2px solid #21f328ff; border-radius: 8px; padding: 10px; margin: 15px 0; display: none; flex: 1 1 100%;">
<summary style="cursor: pointer; font-weight: bold; color: rgba(26, 141, 3, 1);">🔗 Generar enlaces</summary>
<div style="padding: 15px;display: flex;flex-wrap: wrap;gap: 10px 16px;align-items: flex-end;">
<label style="display: flex;flex-direction: column;gap: 6px;min-width: 220px;flex: 1 1 100%;">
Este servidor
<input type="url" value="${location.protocol}//${location.hostname}:${location.port}${location.pathname}?sublogin=${mid}" style="font-size: 10px; font-weight: bold; color: #000;">
</label>
</div>
</details>
</div>
<hr>
<button class="saveico" id="${btn_guardar}">
<img src="static/floppy_disk_green.png" />
<br>Guardar
</button>
<button class="delico" id="${btn_borrar}">
<img src="static/garbage.png" />
<br>Borrar
</button>
<button type="button" id="${btn_ver_monedero}" class="opicon">
<img src="static/cash_flow.png" />
<br>Movimientos
</button>
<button class="opicon" onclick="setUrlHash('personas')" style="float: right;"> <!-- Align to the right -->
<img src="static/exit.png" />
<br>Salir
</button>
<button class="opicon" onclick="window.print()" style="float: right;"> <!-- Align to the right -->
<img src="static/printer2.png" />
<br>Imprimir
</button>
</fieldset>
`;
var resized = '';
@@ -110,6 +116,7 @@ PAGES.personas = {
document.getElementById(field_nombre).value = data['Nombre'] || '';
document.getElementById(field_zona).value = data['Region'] || '';
document.getElementById(field_anilla).value = data['SC_Anilla'] || '';
document.getElementById(field_oculto).checked = data['Oculto'] || false;
// set fallback image immediately
document.getElementById(render_foto).src = data['Foto'] || 'static/ico/user_generic.png';
resized = data['Foto'] || 'static/ico/user_generic.png';
@@ -124,7 +131,6 @@ PAGES.personas = {
.catch(() => {});
document.getElementById(field_notas).value = data['markdown'] || '';
document.getElementById(field_monedero_balance).value = data['Monedero_Balance'] || 0;
document.getElementById(field_monedero_notas).value = data['Monedero_Notas'] || '';
}
if (typeof data == 'string') {
TS_decrypt(
@@ -166,10 +172,10 @@ PAGES.personas = {
Region: document.getElementById(field_zona).value,
Roles: dt.getAll('perm').join(',') + ',',
SC_Anilla: document.getElementById(field_anilla).value,
Oculto: document.getElementById(field_oculto).checked,
// Foto moved to PouchDB attachment named 'foto'
markdown: document.getElementById(field_notas).value,
Monedero_Balance: parseFloat(document.getElementById(field_monedero_balance).value) || 0,
Monedero_Notas: document.getElementById(field_monedero_notas).value,
};
document.getElementById('actionStatus').style.display = 'block';
DB.put('personas', mid, data)
@@ -204,7 +210,7 @@ PAGES.personas = {
});
};
document.getElementById(btn_ver_monedero).onclick = () => {
setUrlHash('pagos'); // Navigate to pagos and show transactions for this person
setUrlHash('pagos?filter=Persona:' + encodeURIComponent(mid)); // Navigate to pagos and show transactions for this person
};
document.getElementById(btn_borrar).onclick = () => {
if (confirm('¿Quieres borrar esta persona?') == true) {

View File

@@ -61,6 +61,36 @@ PAGES.supercafe = {
divact.innerHTML = '';
addCategory_Personas(divact, SC_Personas, currentPersonaID, (value) => {
document.getElementById(field_persona).value = value;
// Check for outstanding debts when person is selected
DB.list('supercafe').then((rows) => {
var deudasCount = 0;
var processed = 0;
var total = rows.length;
if (total === 0) return;
// Count debts for this person
rows.forEach((row) => {
TS_decrypt(row.data, SECRET, (data) => {
if (data.Persona == value && data.Estado == 'Deuda') {
deudasCount++;
}
processed++;
// When all rows are processed, show warning if needed
if (processed === total && deudasCount >= 3) {
var tts_msg = `Atención: Esta persona tiene ${deudasCount} comandas en deuda. No se podrá guardar el pedido.`;
TS_SayTTS(tts_msg)
toastr.warning(`Esta persona tiene ${deudasCount} comandas en deuda. No se podrá guardar el pedido.`, '', {
timeOut: 5000
});
}
}, 'supercafe', row.id);
});
}).catch((e) => {
console.warn('Error checking debts', e);
});
});
Object.entries(SC_actions).forEach((category) => {
addCategory(
@@ -115,33 +145,80 @@ PAGES.supercafe = {
return;
}
// Disable button after validation passes
guardarBtn.disabled = true;
guardarBtn.style.opacity = '0.5';
var personaId = document.getElementById(field_persona).value;
var data = {
Fecha: document.getElementById(field_fecha).value,
Persona: document.getElementById(field_persona).value,
Comanda: JSON.stringify(currentData),
Notas: document.getElementById(field_notas).value,
Estado: document.getElementById(field_estado).value.replace('%%', 'Pedido'),
};
document.getElementById('actionStatus').style.display = 'block';
DB.put('supercafe', mid, data)
.then(() => {
toastr.success('Guardado!');
setTimeout(() => {
document.getElementById('actionStatus').style.display = 'none';
setUrlHash('supercafe');
}, SAVE_WAIT);
})
.catch((e) => {
console.warn('DB.put error', e);
guardarBtn.disabled = false;
guardarBtn.style.opacity = '1';
document.getElementById('actionStatus').style.display = 'none';
toastr.error('Error al guardar la comanda');
// Check for outstanding debts
DB.list('supercafe').then((rows) => {
var deudasCount = 0;
var processed = 0;
var total = rows.length;
if (total === 0) {
// No commands, proceed to save
proceedToSave();
return;
}
// Count debts for this person
rows.forEach((row) => {
TS_decrypt(row.data, SECRET, (data) => {
if (data.Persona == personaId && data.Estado == 'Deuda') {
deudasCount++;
}
processed++;
// When all rows are processed, check if we can save
if (processed === total) {
if (deudasCount >= 3) {
toastr.error('Esta persona tiene más de 3 comandas en deuda. No se puede realizar el pedido.');
// Delete the comanda if it was created
if (mid) {
DB.del('supercafe', mid).then(() => {
setTimeout(() => {
setUrlHash('supercafe');
}, 1000);
});
}
} else {
proceedToSave();
}
}
}, 'supercafe', row.id);
});
}).catch((e) => {
console.warn('Error checking debts', e);
toastr.error('Error al verificar las deudas');
});
function proceedToSave() {
// Disable button after validation passes
guardarBtn.disabled = true;
guardarBtn.style.opacity = '0.5';
var data = {
Fecha: document.getElementById(field_fecha).value,
Persona: personaId,
Comanda: JSON.stringify(currentData),
Notas: document.getElementById(field_notas).value,
Estado: document.getElementById(field_estado).value.replace('%%', 'Pedido'),
};
document.getElementById('actionStatus').style.display = 'block';
DB.put('supercafe', mid, data)
.then(() => {
toastr.success('Guardado!');
setTimeout(() => {
document.getElementById('actionStatus').style.display = 'none';
setUrlHash('supercafe');
}, SAVE_WAIT);
})
.catch((e) => {
console.warn('DB.put error', e);
guardarBtn.disabled = false;
guardarBtn.style.opacity = '1';
document.getElementById('actionStatus').style.display = 'none';
toastr.error('Error al guardar la comanda');
});
}
};
document.getElementById(btn_borrar).onclick = () => {
if (
@@ -170,7 +247,7 @@ PAGES.supercafe = {
var ev = setTimeout(() => {
tts = true;
console.log('TTS Enabled');
toastr.info('Texto a voz disponible');
//toastr.info('Texto a voz disponible');
}, 6500);
EventListeners.Timeout.push(ev);
const tablebody = safeuuid();
@@ -193,7 +270,7 @@ PAGES.supercafe = {
open
>
<summary>Todas las comandas</summary>
<div id="cont1"></div>
<div id="${tablebody}"></div>
</details>
<br />
<details
@@ -201,9 +278,13 @@ PAGES.supercafe = {
open
>
<summary>Deudas</summary>
<div id="cont2"></div>
<div id="${tablebody2}"></div>
</details>
`;
document.getElementById(tts_check).checked = localStorage.getItem('TELESEC_TTS_ENABLED') === 'true';
document.getElementById(tts_check).onchange = function () {
localStorage.setItem('TELESEC_TTS_ENABLED', this.checked);
}
var config = [
{
key: 'Persona',
@@ -250,11 +331,12 @@ PAGES.supercafe = {
document.getElementById(totalprecio).innerText = tot;
return tot;
}
var ttS_data = {};
TS_IndexElement(
'supercafe',
config,
'supercafe',
document.querySelector('#cont1'),
document.getElementById(tablebody),
(data, new_tr) => {
// new_tr.style.backgroundColor = "#FFCCCB";
comandasTot[data._key] = SC_priceCalc(JSON.parse(data.Comanda))[0];
@@ -285,13 +367,36 @@ PAGES.supercafe = {
}
if (old[key] != data.Estado) {
if (tts && document.getElementById(tts_check).checked) {
var msg = `Comanda de ${SC_Personas[data.Persona].Region}. - ${
JSON.parse(data.Comanda)['Selección']
}. - ${SC_Personas[data.Persona].Nombre}. - ${data.Estado}`;
let utterance = new SpeechSynthesisUtterance(msg);
utterance.rate = 0.9;
// utterance.voice = speechSynthesis.getVoices()[7]
speechSynthesis.speak(utterance);
if (ttS_data[data.Region] == undefined) {
ttS_data[data.Region] = {};
}
ttS_data[data.Region][data._key] = data.Estado;
var allReady = true;
Object.values(ttS_data[data.Region]).forEach((estado) => {
if (estado != 'Listo') {
allReady = false;
}
});
if (allReady) {
var msgRegion = `Hola, ${SC_Personas[data.Persona].Region}. - Vamos a entregar vuestro pedido. ¡Que aproveche!`;
TS_SayTTS(msgRegion)
} if (data.Estado == 'Entregado') {
var msgEntregado = `El pedido de ${SC_Personas[data.Persona].Nombre} en ${SC_Personas[data.Persona].Region} ha sido entregado.`;
TS_SayTTS(msgEntregado)
} else if (data.Estado == 'En preparación') {
var msgPreparacion = `El pedido de ${SC_Personas[data.Persona].Nombre} en ${SC_Personas[data.Persona].Region} está en preparación.`;
TS_SayTTS(msgPreparacion)
} else if (data.Estado == 'Listo') {
var msgListo = `El pedido de ${SC_Personas[data.Persona].Nombre} en ${SC_Personas[data.Persona].Region} está listo para ser entregado.`;
TS_SayTTS(msgListo)
} else if (data.Estado == 'Pedido') {
var msgPedido = `Se ha realizado un nuevo pedido para ${SC_Personas[data.Persona].Nombre} en ${SC_Personas[data.Persona].Region}.`;
TS_SayTTS(msgPedido)
} else {
var msg = `Comanda de ${SC_Personas[data.Persona].Region}. - ${JSON.parse(data.Comanda)['Selección']
}. - ${SC_Personas[data.Persona].Nombre}. - ${data.Estado}`;
TS_SayTTS(msg)
}
}
}
old[key] = data.Estado;
@@ -303,7 +408,7 @@ PAGES.supercafe = {
'supercafe',
config,
'supercafe',
document.querySelector('#cont2'),
document.getElementById(tablebody2),
(data, new_tr) => {
// new_tr.style.backgroundColor = "#FFCCCB";
comandasTot[data._key] = 0; // No mostrar comandas en deuda.
@@ -335,13 +440,8 @@ PAGES.supercafe = {
}
if (old[key] != data.Estado) {
if (tts && document.getElementById(tts_check).checked) {
var msg = `Comanda de ${SC_Personas[data.Persona].Region}. - ${
JSON.parse(data.Comanda)['Selección']
}. - ${SC_Personas[data.Persona].Nombre}. - ${data.Estado}`;
let utterance = new SpeechSynthesisUtterance(msg);
utterance.rate = 0.9;
// utterance.voice = speechSynthesis.getVoices()[7]
speechSynthesis.speak(utterance);
var msg = `La comanda de ${SC_Personas[data.Persona].Nombre} en ${SC_Personas[data.Persona].Region} ha pasado a deuda.`;
TS_SayTTS(msg)
}
}
old[key] = data.Estado;

View File

@@ -1,5 +1,130 @@
let newWorker;
const APP_VERSION = '%%VERSIONCO%%';
const VERSION_CHECK_INTERVAL_MS = 10 * 60 * 1000;
let lastVersionCheckTs = 0;
let updatePromptShown = false;
function sendCouchUrlPrefixToServiceWorker(registration) {
if (!registration) {
return;
}
const couchUrlPrefix = (localStorage.getItem('TELESEC_COUCH_URL') || '').trim();
const message = {
type: 'SET_COUCH_URL_PREFIX',
url: couchUrlPrefix
};
if (registration.active) {
registration.active.postMessage(message);
}
if (registration.waiting) {
registration.waiting.postMessage(message);
}
if (registration.installing) {
registration.installing.postMessage(message);
}
}
async function checkAppVersion(force = false) {
const now = Date.now();
if (!force && now - lastVersionCheckTs < VERSION_CHECK_INTERVAL_MS) {
return;
}
if (!navigator.onLine) {
return;
}
lastVersionCheckTs = now;
try {
const response = await fetch(`/version.json?t=${Date.now()}`, {
cache: 'no-cache'
});
if (!response.ok) {
return;
}
const data = await response.json();
if (!data || !data.version) {
return;
}
if (data.version !== APP_VERSION) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
}
if (!updatePromptShown) {
showUpdateBar();
updatePromptShown = true;
}
} else {
updatePromptShown = false;
}
} catch (error) {
console.warn('No se pudo comprobar la versión remota:', error);
}
}
async function ActualizarProgramaTeleSec() {
if (!confirm('Se borrará la caché local del programa y se recargará la aplicación. ¿Continuar?')) {
return;
}
let cacheCleared = true;
try {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
const sendSkipWaiting = (worker) => {
worker.postMessage({ type: 'SKIP_WAITING' });
};
if (registration.waiting) {
sendSkipWaiting(registration.waiting);
} else if (registration.installing) {
await new Promise((resolve) => {
const installingWorker = registration.installing;
const onStateChange = () => {
if (installingWorker.state === 'installed') {
installingWorker.removeEventListener('statechange', onStateChange);
sendSkipWaiting(installingWorker);
resolve();
}
};
installingWorker.addEventListener('statechange', onStateChange);
onStateChange();
setTimeout(resolve, 2500);
});
}
}
}
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
}
} catch (error) {
cacheCleared = false;
console.error('No se pudo limpiar la caché completamente:', error);
if (typeof toastr !== 'undefined') {
toastr.error('No se pudo limpiar toda la caché. Recargando igualmente...');
}
}
if (cacheCleared && typeof toastr !== 'undefined') {
toastr.success('Caché limpiada. Recargando aplicación...');
}
setTimeout(() => {
location.reload();
}, 700);
}
function showUpdateBar() {
let snackbar = document.getElementById('snackbar');
snackbar.className = 'show';
@@ -8,30 +133,41 @@ function showUpdateBar() {
// The click event on the pop up notification
document.getElementById('reload').addEventListener('click', function () {
setTimeout(() => {
removeCache();
ActualizarProgramaTeleSec();
}, 1000);
newWorker.postMessage({ action: 'skipWaiting' });
if (newWorker) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then((reg) => {
const wireRegistration = (reg) => {
reg.addEventListener('updatefound', () => {
// A wild service worker has appeared in reg.installing!
newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
// Has network.state changed?
switch (newWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
// new update available
showUpdateBar();
}
// No update available
break;
}
});
});
};
navigator.serviceWorker.getRegistration().then(async (reg) => {
if (!reg) {
reg = await navigator.serviceWorker.register('sw.js');
} else {
await reg.update();
}
wireRegistration(reg);
sendCouchUrlPrefixToServiceWorker(reg);
checkAppVersion(true);
setInterval(checkAppVersion, VERSION_CHECK_INTERVAL_MS);
});
let refreshing;
@@ -40,4 +176,20 @@ if ('serviceWorker' in navigator) {
window.location.reload();
refreshing = true;
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkAppVersion();
}
});
window.addEventListener('storage', (event) => {
if (event.key !== 'TELESEC_COUCH_URL') {
return;
}
navigator.serviceWorker.getRegistration().then((registration) => {
sendCouchUrlPrefixToServiceWorker(registration);
});
});
}

View File

@@ -1,10 +1,29 @@
var CACHE = 'telesec_%%VERSIONCO%%';
importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.1.2/workbox-sw.js');
let couchUrlPrefix = '';
function normalizePrefix(url) {
if (!url || typeof url !== 'string') {
return '';
}
const trimmedUrl = url.trim();
if (!trimmedUrl) {
return '';
}
return trimmedUrl.replace(/\/+$/, '');
}
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'SET_COUCH_URL_PREFIX') {
couchUrlPrefix = normalizePrefix(event.data.url);
}
});
// workbox.routing.registerRoute(
@@ -16,8 +35,17 @@ self.addEventListener('message', (event) => {
// All but couchdb
workbox.routing.registerRoute(
({ url }) => !url.pathname.startsWith('/_couchdb/') && url.origin === self.location.origin,
new workbox.strategies.NetworkFirst({
({ request, url }) => {
const requestUrl = request && request.url ? request.url : url.href;
const normalizedRequestUrl = normalizePrefix(requestUrl);
if (couchUrlPrefix && normalizedRequestUrl.startsWith(couchUrlPrefix)) {
return false;
}
return !url.pathname.startsWith('/_couchdb/') && url.origin === self.location.origin;
},
new workbox.strategies.CacheFirst({
cacheName: CACHE,
})
);

3
src/version.json Normal file
View File

@@ -0,0 +1,3 @@
{
"version": "%%VERSIONCO%%"
}