Compare commits
41 Commits
revert-18-
...
v2026.03.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c6ba39f3 | ||
|
|
03f52c8a92 | ||
|
|
f655a736b3 | ||
|
|
89a68f27da | ||
|
|
4d322e5696 | ||
|
|
0138e0ca69 | ||
|
|
3e8542c9de | ||
|
|
90df81d308 | ||
|
|
53941da35c | ||
|
|
105c911c59 | ||
|
|
cb12894455 | ||
|
|
9d808ed63e | ||
|
|
d0593d3d46 | ||
|
|
8b7d0258ae | ||
|
|
9a760a1d24 | ||
|
|
e1f780ea11 | ||
|
|
7ad2e9c142 | ||
|
|
879554a7ab | ||
|
|
0ef6e5a233 | ||
|
|
d905e86bbf | ||
|
|
3764473b5b | ||
|
|
382e31158a | ||
|
|
09a9a95df0 | ||
|
|
b04dbbf19d | ||
|
|
7619444556 | ||
|
|
076aa45337 | ||
|
|
0b1419fae2 | ||
|
|
74afb2a499 | ||
|
|
543d1c3202 | ||
|
|
75947d3468 | ||
|
|
9ab0472e2a | ||
|
|
aa993df2bf | ||
|
|
e0da65811e | ||
|
|
eb6a956cdc | ||
|
|
dc4ba25b20 | ||
|
|
129188c022 | ||
|
|
9d4ce881c6 | ||
|
|
4e1727adc3 | ||
|
|
db5b07bb44 | ||
|
|
61b8cb8af4 | ||
|
|
2ee03aa204 |
46
.github/workflows/windows-agent-release.yml
vendored
Normal 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
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"tobermory.es6-string-html",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
92
README.md
@@ -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`.
|
||||
|
||||
BIN
assets/static/appico/message.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/static/appico/piggy_bank.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/static/cash_flow.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
20
assets/static/chart.umd.min.js
vendored
Normal 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
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/static/exit.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/static/find.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
assets/static/floppy_disk_green.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/static/garbage.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/static/printer2.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
3
build.py
@@ -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")
|
||||
|
||||
273
python_sdk/telesec_couchdb.py
Normal file
@@ -0,0 +1,273 @@
|
||||
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_encrypt(input_value: Any, secret: str) -> str:
|
||||
if not isinstance(input_value, str):
|
||||
payload = json.dumps(input_value, separators=(",", ":"), ensure_ascii=False)
|
||||
else:
|
||||
payload = input_value
|
||||
|
||||
payload_bytes = payload.encode("utf-8")
|
||||
salt = os.urandom(8)
|
||||
|
||||
# OpenSSL EVP_BytesToKey (MD5)
|
||||
dx = b""
|
||||
salted = b""
|
||||
while len(salted) < 48: # 32 key + 16 iv
|
||||
dx = hashlib.md5(dx + secret.encode() + salt).digest()
|
||||
salted += dx
|
||||
|
||||
key = salted[:32]
|
||||
iv = salted[32:48]
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, 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}}}"
|
||||
|
||||
@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
|
||||
525
python_sdk/windows_agent.py
Normal file
@@ -0,0 +1,525 @@
|
||||
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, List
|
||||
|
||||
import psutil
|
||||
import base64
|
||||
import email.utils
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
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_encrypt(input_value: Any, secret: str) -> str:
|
||||
if not isinstance(input_value, str):
|
||||
payload = json.dumps(input_value, separators=(",", ":"), ensure_ascii=False)
|
||||
else:
|
||||
payload = input_value
|
||||
|
||||
payload_bytes = payload.encode("utf-8")
|
||||
salt = os.urandom(8)
|
||||
|
||||
# OpenSSL EVP_BytesToKey (MD5)
|
||||
dx = b""
|
||||
salted = b""
|
||||
while len(salted) < 48: # 32 key + 16 iv
|
||||
dx = hashlib.md5(dx + secret.encode() + salt).digest()
|
||||
salted += dx
|
||||
|
||||
key = salted[:32]
|
||||
iv = salted[32:48]
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, 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}}}"
|
||||
|
||||
@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
|
||||
|
||||
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="", 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_false", help="Ejecutar una sola iteración")
|
||||
parser.add_argument("--dry-run", action="store_false", 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())
|
||||
@@ -1 +1,3 @@
|
||||
requests
|
||||
requests
|
||||
pycryptodome
|
||||
psutil
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
@@ -555,9 +949,11 @@ function TS_decrypt(input, secret, callback, table, id) {
|
||||
}
|
||||
|
||||
// Encrypted format marker: RSA{<ciphertext>} where <ciphertext> is CryptoJS AES output
|
||||
// console.debug(input);
|
||||
if (input.startsWith('RSA{') && input.endsWith('}') && typeof CryptoJS !== 'undefined') {
|
||||
try {
|
||||
var data = input.slice(4, -1);
|
||||
// console.debug("TS_decrypt secret:", ">" + secret + "<", typeof secret, secret?.length);
|
||||
var words = CryptoJS.AES.decrypt(data, secret);
|
||||
var decryptedUtf8 = null;
|
||||
try {
|
||||
@@ -700,36 +1096,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 +1147,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 +1215,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 +1229,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 +1250,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 +1318,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 +1438,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 +1689,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 +1704,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 +1728,7 @@ function BuildQR(mid, label) {
|
||||
dim: 150,
|
||||
pad: 0,
|
||||
mtx: -1,
|
||||
ecl: 'L',
|
||||
ecl: 'S',
|
||||
ecb: 0,
|
||||
pal: ['#000000', '#ffffff'],
|
||||
vrb: 0,
|
||||
@@ -1299,6 +1746,7 @@ var PAGES = {};
|
||||
var PERMS = {
|
||||
ADMIN: 'Administrador',
|
||||
};
|
||||
|
||||
function checkRole(role) {
|
||||
var roles = SUB_LOGGED_IN_DETAILS.Roles || '';
|
||||
var rolesArr = roles.split(',');
|
||||
@@ -1317,7 +1765,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 +1811,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 +1887,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 +1924,7 @@ var BootIntervalID = setInterval(() => {
|
||||
}
|
||||
} else {
|
||||
SetPages();
|
||||
open_page(location.hash.replace('#', ''));
|
||||
open_page(location.hash.replace('#', '').split("?")[0]);
|
||||
}
|
||||
clearInterval(BootIntervalID);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
38
src/db.js
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -0,0 +1,294 @@
|
||||
PERMS['mensajes'] = 'Mensajes';
|
||||
PERMS['mensajes:edit'] = '> 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(''));
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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, '"')}" ${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();
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
166
src/pwa.js
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
32
src/sw.js
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "%%VERSIONCO%%"
|
||||
}
|
||||