Equipo y usuarios (roster, accesos desde el sitio), responsable obligatorio, pendiente ferretería EIT/TIMSA (14-oct)

This commit is contained in:
Leopoldo Rodríguez 2026-10-13 20:25:59 -06:00
parent 9f3d41ed49
commit 7a8f95aa8c
20 changed files with 1039 additions and 51 deletions

View File

@ -14,6 +14,7 @@ Servidor: `git.helgen.online` · dominio público: `https://drogheda.helgen.onli
| **Inventario (ítems y movimientos)** | `/srv/projects/drogheda/inventario` | las escribe el backend — **13-oct** |
| **Sitios** | `/srv/projects/drogheda/sitios` | las escribe el backend — **13-oct** |
| **Plan de producción** | `/srv/projects/drogheda/plan-produccion.json` | lo escribe el backend (admin) — **13-oct** |
| **Equipo (miembros, bitácora de accesos)** | `/srv/projects/drogheda/equipo` | las escribe el backend — **14-oct** |
| Usuarios (HTTP Basic) | `/etc/nginx/hbox-docs.htpasswd` | `htpasswd` |
**Lo importante:** `deploy.sh` publica **solo el sitio**. Si cambió `backend/app.py` —como en esta
@ -214,9 +215,44 @@ rsync -az leo@git.helgen.online:/srv/projects/drogheda/sitios/ ~/respaldo-si
rsync -az leo@git.helgen.online:/srv/projects/drogheda/plan-produccion.json ~/respaldo-plan/
```
## A3 · 14-oct: equipo y usuarios
Agrega la colección `equipo` (quién es quién; los responsables de los pendientes), la administración
de accesos desde el sitio (`/tableros/equipo.html`, solo admin) y hace obligatorio el responsable en
cada pendiente. Compatible con lo ya desplegado: los pendientes viejos siguen funcionando; la siembra
les calcula `responsable_ids` y pone `leo` solo donde el responsable estaba vacío.
```bash
# Mac
cd ~/Documents/Claude/Projects/Drogheda/hbox-docs && git add -A && git commit -m "equipo, usuarios y responsables (14-oct)" && git push && ./scripts/deploy.sh
# servidor
ssh leo@git.helgen.online
cd /srv/projects/drogheda/hbox-docs && git pull
sudo systemctl restart actas-hbox
./backend/venv/bin/python3 backend/seed_1013.py --usuario leo # crea equipo/ (9 miembros, sin accesos) + el pendiente de ferretería; no duplica
curl -s -H 'X-Remote-User: leo' http://127.0.0.1:8081/api/equipo | head -c 200
```
La carpeta `equipo/` la crea la siembra (o el backend al primer alta) junto a `/actas`; no hay
variables nuevas que fijar (`EQUIPO_DIR`, `HTPASSWD_LOCK` vienen comentadas en el `.service`).
**Para crear o quitar accesos desde la página, el servicio tiene que poder escribir el htpasswd** —
es lo mismo que ya pide «Mi cuenta» (§7): `sudo chown leo:www-data /etc/nginx/hbox-docs.htpasswd &&
sudo chmod 640 /etc/nginx/hbox-docs.htpasswd`. Si no está hecho, la página de Equipo lo avisa y solo
deja editar miembros. A partir de aquí los usuarios se crean en **Equipo → Nuevo usuario / miembro**
(la contraseña inicial se muestra una sola vez), no con `htpasswd` en el servidor. Las cuentas que ya
existen y no son miembro (p. ej. `luz`) aparecen como «cuentas con acceso que no están en el equipo»:
dales de alta como miembro sin marcar «crear acceso».
Verificar: `/tableros/equipo.html` como leo (botones) y como un técnico (solo lectura); crear un
pendiente sin responsable debe rechazarse; `backend/tests-1014.sh` reproduce todo con un htpasswd temporal.
| Síntoma | Causa | Arreglo |
|---|---|---|
| Los tableros dicen «No se pudo cargar (HTTP 404)» | backend viejo | paso 2 (`git pull` + restart) |
| «El servicio no tiene permiso de escribir el archivo de usuarios» al crear un acceso | falta el `chown`/`chmod` del htpasswd | §7 |
| «Falta el responsable del pendiente» al crear | regla del 14-oct | elige a alguien del equipo (o escríbelo en texto libre) |
| «No puedo identificarte (falta X-Remote-User…)» al crear o mover | nginx no manda la cabecera | paso 4 del despliegue anterior |
| El tablero sale vacío | falta la siembra | paso 3 |
| «Solo un administrador…» al borrar o editar sitios/plan | ese usuario no está en `ACTAS_ADMINS` | así debe ser; pide a Leo |

View File

@ -18,6 +18,8 @@ copian tal cual; los `.md` llevan frontmatter con `title` y `date`.
| `sitios/sitio.html` | Página de un sitio (`?id=icave`) | 🚜 Campo |
| `formularios/instalacion.html` | Acta de instalación en campo | 🚜 Campo → «Instalación en el puerto» (junto al checklist de puesta en marcha) |
| `tableros/equipo.html` | Equipo y accesos (quién es quién; usuarios del sitio, solo admin) — **14-oct** | 📋 Tableros (y enlazar desde ⚙ Cuenta → Administración) |
Archivos de apoyo (no van al nav): `tableros/tablero.css`, `tableros/kanban.js`.
Sugerencia para `actas/index.md` (no lo edité, para no chocar con la reorganización): agregar la fila
@ -31,14 +33,19 @@ antenas, RTK FIX, fotos · [Sitios](../sitios/index.md)» en la tabla **Campo**.
| `formularios/acta.js` | nuevo tipo de ítem `sitio` (select alimentado por `/api/sitios`; `guardaEn:'sitio'`); `fillFrom` respeta un select cuyas opciones aún no cargan |
| `formularios/envio.html`, `recepcion.html`, `diagnostico.html`, `dano.html` | un ítem `sitio` (opcional) en la sección 0; en daño solo aparece si fue en campo / transporte / no se sabe |
| `unidad.html` | bloque **Trazabilidad**: cadena de producción (tarjeta → módulos → integración → ensamble → configuración) y cadena de campo (envío → recepción → instalación → diagnósticos → daños) con sitio y fechas |
| `tableros/kanban.js`, `tablero.css` (14-oct) | responsables = miembros de `/api/equipo` (chips, varios por pendiente, texto libre opcional, obligatorio al crear); las tarjetas muestran el nombre; filtro por id; `Kanban.nombresResp()` para otras páginas |
| `tableros/kanban.html`, `ingenieria.html`, `produccion.html`, `index.md` (14-oct) | enlace «👥 Equipo»; producción muestra el nombre del responsable en bloqueos |
| `admin.html` (14-oct) | enlace «Usuarios y equipo →» (las funciones existentes no se movieron) |
| `cuenta.html` (14-oct) | una línea: si no tienes acceso, pídeselo a Leo (Administración → Usuarios y equipo) |
## Backend y despliegue
| Ruta | Cambio |
|---|---|
| `backend/app.py` | colecciones `tareas`, `plan`, `inventario` (+ `movimientos`), `sitios`; `/api/actas` y `/api/unidad` exponen `sitio`/`fecha`/`componentes` |
| `backend/seed_1013.py` | **nuevo** — siembra idempotente (plan, 4 sitios, 18 ítems, 12 pendientes; `--extra` para importar Notion) |
| `backend/app.py` | colecciones `tareas`, `plan`, `inventario` (+ `movimientos`), `sitios`; `/api/actas` y `/api/unidad` exponen `sitio`/`fecha`/`componentes`. **14-oct:** colección `equipo` (`GET/POST /api/equipo`, `PATCH /api/equipo/miembro`), accesos (`POST /api/usuarios`, `DELETE /api/usuario`), `responsable` obligatorio + `responsable_ids`, `resumen.por_responsable` |
| `backend/seed_1013.py` | **nuevo** — siembra idempotente (plan, 4 sitios, 18 ítems, 13 pendientes; `--extra` para importar Notion). **14-oct:** `sembrar_equipo` (9 miembros, sin accesos), pendiente `t-ferreteria-eit-timsa`, completa responsables vacíos y `responsable_ids` |
| `backend/tests-1013.sh`, `backend/TESTS-1013.md` | **nuevos** — pruebas con `curl` (61) y resumen de las pruebas de navegador |
| `backend/tests-1014.sh`, `backend/TESTS-1014.md` | **nuevos (14-oct)** — equipo, accesos con htpasswd temporal, responsables (57) |
| `backend/README.md` | sección 5: colecciones, variables, ejemplos `curl` por endpoint |
| `backend/actas-hbox.service` | variables `TAREAS_DIR`, `INVENTARIO_DIR`, `SITIOS_DIR`, `PLAN_FILE` comentadas (valores por omisión junto a `/actas`) |
| `DESPLIEGUE.md` | sección **A2 · Despliegue 13-oct** (git pull, mkdir, restart, siembra, verificación, respaldo, problemas frecuentes) y filas nuevas en «Qué vive dónde» |

View File

@ -73,6 +73,8 @@ cada cambio. Carpetas (variables de entorno, con su valor por omisión junto a `
| `INVENTARIO_DIR` | `/srv/projects/drogheda/inventario` | `items/` y `movimientos/` |
| `SITIOS_DIR` | `/srv/projects/drogheda/sitios` | un JSON por sitio (`icave.json`…) |
| `PLAN_FILE` | `/srv/projects/drogheda/plan-produccion.json` | el plan del pedido (570 → 113/327/130) |
| `EQUIPO_DIR` | `/srv/projects/drogheda/equipo` | miembros del equipo (14-oct) + `_accesos.csv` |
| `HTPASSWD_LOCK` | `/srv/projects/drogheda/.htpasswd.lock` | candado para escribir el htpasswd desde Equipo |
Reglas comunes: toda escritura exige el usuario autenticado (`X-Remote-User`; sin él, 403);
cada registro guarda `creado_por/creado_en/modificado_por/modificado_en` y un `historial`
@ -159,3 +161,43 @@ curl -s -u $U "$D/api/sitio/unidades?id=icave"
campo `sitio` sea el slug del sitio (`coincidencia: "sitio"`), y también las que solo coinciden por
texto (`terminal` de la flota, destino del envío) con el nombre o un alias (`coincidencia: "texto"`),
agrupadas por unidad con su marca de daño vigente.
## 6. Equipo y accesos (14-oct)
Dos cosas distintas: un **miembro del equipo** (`EQUIPO_DIR`, omisión `/srv/projects/drogheda/equipo`,
un JSON por persona) es quien puede ser **responsable** de un pendiente; un **acceso** es una línea en
el htpasswd de nginx. `tiene_login` se deduce del htpasswd al leer; `es_admin` de `ACTAS_ADMINS`.
Los accesos se crean y quitan desde la página **Equipo** (admin), con la misma rutina `$apr1$` y la misma
escritura atómica que `/api/password`, bajo un `flock` (`HTPASSWD_LOCK`, omisión
`/srv/projects/drogheda/.htpasswd.lock`). Bitácora de altas/bajas en `equipo/_accesos.csv`.
Requisito en el servidor: el servicio debe poder **escribir** el htpasswd (`chown leo:www-data` +
`chmod 640`, §7 de DESPLIEGUE.md).
Campos del miembro: `id` (= usuario del sitio si lo tiene), `nombre`, `rol`, `area` ∈ direccion·operaciones·
ingenieria·layout·produccion·pruebas·fabricacion·software·campo·asistente, `correo`, `telefono`, `activo`.
```bash
curl -s -u $U "$D/api/equipo" # activos, con tiene_login, es_admin y pendientes {abiertos, vencidas, por estado}
curl -s -u $U "$D/api/equipo?todos=1" # incluye inactivos; para admin trae logins_sin_miembro
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/equipo \
-d '{"id":"adriana","nombre":"Adriana","rol":"Operaciones; lleva el PM","area":"operaciones"}' # admin; sin acceso al sitio
curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/equipo/miembro?id=aldair" -d '{"rol":"Fabricación y ensamble"}' # admin
curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/equipo/miembro?id=aldair" -d '{"activo":false}' # desactivar (nunca se borra)
# acceso al sitio: crea la línea en el htpasswd (409 si existe; 400 si la contraseña es más corta que PASS_MIN)
# y crea/actualiza al miembro. La contraseña inicial se pasa en persona; la cambia en /cuenta.html.
curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/usuarios \
-d '{"usuario":"adriana","password_inicial":"operaciones-2026","nombre":"Adriana","rol":"Operaciones","area":"operaciones","correo":"adriana@helgen.tech"}'
# quitar el acceso: borra la línea y deja al miembro activo=false. Se niega a quitarte a ti mismo (400)
# y a quitar al último administrador con acceso (409).
curl -s -u $U -X DELETE "$D/api/usuario?u=adriana"
curl -s -u $U "$D/api/usuarios" # sigue igual: lista de cuentas del htpasswd (admin)
```
**Pendientes (cambio del 14-oct):** `responsable` es obligatorio al crear (400 si falta). Acepta ids de
miembros separados por coma, espacio o `/` (`"leo, Jesús"` → `responsable_ids: ["leo","jesus"]`; los acentos
no importan) y también texto libre si la persona aún no está dada de alta: se guarda y la respuesta trae
`aviso`. `GET /api/tareas?responsable=jesus` filtra por id (o por texto, para los libres) y la respuesta
incluye `equipo` (id → nombre) para pintar nombres. `GET /api/tareas/resumen` agrega `por_responsable`
`{id: {por-hacer, en-curso, bloqueado, hecho, abiertos, vencidas, nombre}}`; los pendientes sin miembro
registrado cuentan bajo `_sin_equipo`.

104
backend/TESTS-1014.md Normal file
View File

@ -0,0 +1,104 @@
# Pruebas del 14-oct — equipo, accesos y responsables
Complementa a `TESTS-1013.md`. Todo corrió en el sandbox contra `backend/app.py` con carpetas temporales,
un **htpasswd temporal** (`HTPASSWD_FILE`, permisos 640, hashes `$apr1$` generados con la misma rutina
del backend) y `ACTAS_ADMINS=leo,alan`. El usuario llega por `X-Remote-User`, como lo fija nginx.
## 1 · Backend — `backend/tests-1014.sh` (57 comprobaciones)
```bash
bash backend/tests-1014.sh /ruta/a/hbox-docs 8097
```
```
## siembra (incluye equipo) — con un pendiente viejo sin responsable y sin responsable_ids
✓ equipo sembrado (9)
✓ pendientes nuevos (12 de 13: t-caja-ethernet ya existía)
✓ responsable vacío completado a leo
✓ responsable_ids recalculado
✓ t-caja-ethernet → leo con historial
✓ segunda corrida: equipo no duplica
✓ segunda corrida: nada que completar
✓ t-ferreteria-eit-timsa sembrada para adriana
## equipo
✓ GET equipo (cualquiera)
✓ tiene_login derivado del htpasswd (leo sí)
✓ adriana sin login
✓ es_admin desde ACTAS_ADMINS
✓ pendientes abiertos por miembro
✓ logins sin miembro solo para admin (bob no admin → [])
✓ logins sin miembro para admin (bob)
✓ POST equipo no admin → 403
✓ POST equipo (admin)
✓ POST equipo duplicado → 409
✓ POST equipo área inválida → 400
✓ PATCH miembro no admin → 403
✓ PATCH miembro (admin) → historial
✓ PATCH activo=false (desactivar, nunca borrar)
✓ GET equipo oculta inactivos
✓ GET equipo?todos=1 los muestra
✓ PATCH inexistente → 404
## accesos (htpasswd temporal)
✓ POST usuarios no admin → 403
✓ contraseña corta → 400
✓ usuario con espacios/mayúsculas → 400
✓ crea acceso a miembro existente (adriana)
✓ línea en el htpasswd con apr1
✓ el hash valida con apr1 (misma rutina que /api/password)
✓ permisos del archivo conservados (640)
✓ duplicado → 409
✓ crea usuario Y miembro nuevos
✓ GET /api/usuarios sigue igual (lista del htpasswd)
✓ la cuenta nueva cambia su propia contraseña por /api/password
✓ bitácora _accesos.csv
✓ DELETE no admin → 403
✓ DELETE a sí mismo → 400
✓ DELETE inexistente → 404
✓ último admin con acceso (alan admin sin login quita a leo) → 409
✓ con dos admins con acceso, alan sí puede quitar a leo
✓ leo ya no está en el htpasswd
✓ miembro leo desactivado (no borrado)
✓ DELETE nuevo1 (admin alan)
## responsable obligatorio y resumen
✓ crear sin responsable → 400
✓ crear con ids (y 'Jesús' con acento → jesus)
✓ texto libre → se acepta con aviso
✓ lista como array también vale
✓ PATCH responsable vacío → 400
✓ PATCH sin tocar responsable sigue funcionando
✓ filtro por id
✓ filtro con acento
✓ GET tareas trae el mapa equipo
✓ resumen por_responsable (jesus)
✓ resumen por_responsable cuenta abiertos
✓ resumen conserva por_pista
RESULTADO: 57 correctas · 0 fallidas · datos en <tmp>
```
| Área | Probado |
|---|---|
| siembra | con un pendiente viejo sin `responsable` ni `responsable_ids`: la primera corrida crea los 9 miembros y 12 pendientes (el viejo se respeta), completa el responsable vacío a `leo` con historial y recalcula `responsable_ids`; la segunda corrida no crea ni cambia nada |
| equipo | GET para cualquiera con `tiene_login` (derivado del htpasswd), `es_admin` (de `ACTAS_ADMINS`) y pendientes por miembro; `logins_sin_miembro` solo para admin; POST/PATCH 403 para no admin; 409 duplicado; 400 área inválida; PATCH con historial; `activo:false` oculta sin borrar (`?todos=1` lo muestra); 404 |
| accesos | POST 403 no admin; 400 contraseña corta y usuario inválido; alta sobre miembro existente (`nuevo_miembro:false`) y alta de usuario+miembro nuevos; línea `$apr1$` verificada con `apr1()`; permisos 640 conservados; 409 duplicado; `GET /api/usuarios` igual que antes; la cuenta nueva cambia su contraseña por `/api/password`; bitácora `_accesos.csv`; DELETE 403 no admin, 400 a sí mismo, 404 inexistente, **409 último administrador con acceso**, ok con dos admins; miembro queda `activo:false` |
| pendientes | crear sin responsable → 400; `"Michelle, Jesús"` → `["michelle","jesus"]`; texto libre aceptado con `aviso`; lista como array; PATCH con responsable vacío → 400; PATCH sin tocar responsable sigue igual; filtro por id y con acento; `GET /api/tareas` trae `equipo`; `resumen.por_responsable` con nombre y abiertos; `por_pista` se conserva |
`backend/tests-1013.sh` se volvió a correr contra este backend (61/61) tras ajustar sus altas de pendientes
a la regla nueva (responsable obligatorio) y el conteo de la siembra (13 pendientes).
## 2 · Páginas — Chromium headless (Playwright), teléfono y escritorio
| Página | Probado | Resultado |
|---|---|---|
| `tableros/equipo.html` (alan) | ve la lista de miembros sin ningún botón ni «Nuevo usuario»; toca «Jesús» y ve sus 2 pendientes con enlace al tablero filtrado | ✓ |
| `tableros/kanban.html` (alan) | las tarjetas muestran el **nombre** («Michelle Márquez», no `michelle`); crear sin responsable → mensaje de validación y no se envía; con dos chips (Adriana, Alán) se crea y la tarjeta muestra ambos nombres; `?responsable=adriana` filtra por id | ✓ |
| `tableros/equipo.html` (leo) | «Nuevo usuario / miembro» con «crear acceso» marcado y contraseña generada → se muestra **una sola vez**; la fila nueva dice acceso «sí»; el hash del htpasswd valida con `apr1()`; «Quitar acceso» → acceso «no» e inactivo; «Editar» guarda el correo; leo no tiene botón para quitarse el acceso | ✓ |
| `tableros/produccion.html` / `ingenieria.html` | los bloqueos y las tarjetas muestran el nombre del responsable | ✓ |
| suite del 13-oct (`test_kanban.py`) | repetida con la regla nueva (elige un chip al crear): drag&drop, Mover a…, historial, borrar admin, plan | ✓ sin errores de consola |
## 3 · Sitio estático
`mkdocs build --strict` sobre una copia: las mismas 55 advertencias preexistentes (PDF/binarios ausentes
en la copia); ninguna sobre `tableros/equipo.html` ni los archivos tocados. Referencias `src`/`href` de
`equipo.html`, `kanban.html`, `ingenieria.html`, `produccion.html`, `admin.html`, `cuenta.html`: 0 rotas.
`node --check tableros/kanban.js`: sin errores.

View File

@ -18,6 +18,8 @@ Environment=ACTAS_ADMINS=leo
#Environment=INVENTARIO_DIR=/srv/projects/drogheda/inventario
#Environment=SITIOS_DIR=/srv/projects/drogheda/sitios
#Environment=PLAN_FILE=/srv/projects/drogheda/plan-produccion.json
#Environment=EQUIPO_DIR=/srv/projects/drogheda/equipo
#Environment=HTPASSWD_LOCK=/srv/projects/drogheda/.htpasswd.lock
Environment=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd
Environment=PASS_MIN=8
Environment=PORT=8081

View File

@ -34,8 +34,11 @@ autenticado crea y modifica, solo un administrador borra:
GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento
GET /api/inventario/movimientos?item_id= · GET /api/inventario/export.csv
GET /api/sitios · POST /api/sitios (admin) · PATCH /api/sitio?id= (admin) · GET /api/sitio/unidades?id=
Equipo y accesos (14-oct): los miembros del equipo NO son lo mismo que los usuarios con login.
GET /api/equipo · POST /api/equipo (admin) · PATCH /api/equipo/miembro?id= (admin)
POST /api/usuarios (admin: crea login en el htpasswd + miembro) · DELETE /api/usuario?u= (admin: quita el login)
"""
import os, re, json, csv, datetime, uuid, hashlib
import os, re, json, csv, datetime, uuid, hashlib, unicodedata, fcntl
from flask import Flask, request, jsonify, abort, send_file
app = Flask(__name__)
@ -741,8 +744,14 @@ TAREAS_DIR = os.environ.get("TAREAS_DIR", os.path.join(DATOS_BASE, "tareas"))
INVENTARIO_DIR = os.environ.get("INVENTARIO_DIR", os.path.join(DATOS_BASE, "inventario"))
SITIOS_DIR = os.environ.get("SITIOS_DIR", os.path.join(DATOS_BASE, "sitios"))
PLAN_FILE = os.environ.get("PLAN_FILE", os.path.join(DATOS_BASE, "plan-produccion.json"))
EQUIPO_DIR = os.environ.get("EQUIPO_DIR", os.path.join(DATOS_BASE, "equipo"))
# Candado para leer-modificar-escribir el htpasswd desde los endpoints de accesos (gunicorn corre
# 2 workers). Vive en la carpeta de datos porque /etc/nginx no es escribible por el servicio.
HTPASSWD_LOCK = os.environ.get("HTPASSWD_LOCK", os.path.join(DATOS_BASE, ".htpasswd.lock"))
PISTAS = ("ingenieria", "produccion", "mecanica", "compras", "campo")
AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion",
"software", "campo", "asistente")
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable",
@ -882,12 +891,304 @@ def _ruta_registro(carpeta, rid):
return os.path.join(carpeta, rid + ".json")
# ------------------------------------------------------------------ equipo (miembros) y accesos
# Un miembro del equipo es quien puede ser responsable de un pendiente. Tener login al sitio es
# otra cosa: tiene_login se deduce del htpasswd al leer, nunca se guarda.
COLS_EQUIPO = ["id", "nombre", "rol", "area", "correo", "telefono", "activo", "creado_por", "creado_en",
"modificado_por", "modificado_en"]
USUARIO_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]{1,29}$")
def _norm_id(s):
"""'Jesús' → 'jesus': minúsculas, sin acentos, solo [a-z0-9_-.]."""
s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower().strip()
return re.sub(r"[^a-z0-9_\-.]", "", s)
def _miembro_valido(d, base=None):
t = dict(base) if base else {}
alta = base is None
if alta or "nombre" in d:
n = _texto(d.get("nombre"), 80)
if not n:
return None, "Falta el nombre del miembro."
t["nombre"] = n
if alta or "rol" in d:
t["rol"] = _texto(d.get("rol"), 120)
if alta or "area" in d:
a = _norm_id(d.get("area")) or "produccion"
if a not in AREAS:
return None, f"Área inválida: {a}. Usa una de {', '.join(AREAS)}."
t["area"] = a
for k, n in (("correo", 120), ("telefono", 40), ("notas", 1000)):
if alta or k in d:
t[k] = _texto(d.get(k), n)
if alta or "activo" in d:
t["activo"] = _bool(d.get("activo"), True)
return t, None
def _guardar_miembro(t):
_escribir_json(_ruta_registro(EQUIPO_DIR, t["id"]), t)
_reescribir_indice(EQUIPO_DIR, _listar_json(EQUIPO_DIR), COLS_EQUIPO)
def _miembros():
"""{id: miembro} de todos (activos e inactivos)."""
return {m["id"]: m for m in _listar_json(EQUIPO_DIR) if m.get("id")}
def _logins():
"""Usuarios que existen en el htpasswd (en minúsculas), o set() si no hay archivo."""
lineas = _leer_htpasswd() or []
return {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l and not l.startswith("#")}
def _registrar_acceso(quien_lo_hizo, accion, usuario):
"""Bitácora de altas y bajas de acceso: equipo/_accesos.csv."""
os.makedirs(EQUIPO_DIR, exist_ok=True)
fp = os.path.join(EQUIPO_DIR, "_accesos.csv")
nuevo = not os.path.exists(fp)
with open(fp, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if nuevo:
w.writerow(["cuando", "quien", "accion", "usuario"])
w.writerow([ahora(), quien_lo_hizo, accion, usuario])
class _candado_htpasswd:
"""flock sobre un archivo aparte, para que dos workers no pisen el htpasswd al mismo tiempo."""
def __enter__(self):
os.makedirs(os.path.dirname(HTPASSWD_LOCK) or ".", exist_ok=True)
self.f = open(HTPASSWD_LOCK, "a+")
fcntl.flock(self.f, fcntl.LOCK_EX)
return self
def __exit__(self, *a):
fcntl.flock(self.f, fcntl.LOCK_UN)
self.f.close()
def _cuenta_vacia():
c = {e: 0 for e in ESTADOS}
c.update({"abiertos": 0, "vencidas": 0})
return c
def _resumen_pendientes_por_miembro():
"""{id: {estado: n, 'abiertos': n, 'vencidas': n}} contando responsable_ids de cada pendiente."""
hoy = datetime.date.today().isoformat()
out = {}
for t in _listar_json(TAREAS_DIR):
ids = t.get("responsable_ids")
if ids is None:
ids, _ = _ids_responsable(t.get("responsable"))
for rid in ids:
r = out.setdefault(rid, _cuenta_vacia())
e = t.get("estado")
if e in ESTADOS:
r[e] += 1
if e != "hecho":
r["abiertos"] += 1
if _vencida(t, hoy):
r["vencidas"] += 1
return out
@app.get("/api/equipo")
def equipo_listar():
todos = request.args.get("todos") == "1"
logins = _logins()
pend = _resumen_pendientes_por_miembro()
out = []
for m in _listar_json(EQUIPO_DIR):
if not todos and m.get("activo", True) is False:
continue
m["tiene_login"] = m["id"].lower() in logins
m["es_admin"] = m["id"].lower() in ADMINS
m["pendientes"] = pend.get(m["id"], _cuenta_vacia())
out.append(m)
out.sort(key=lambda m: (not m.get("activo", True), AREAS.index(m["area"]) if m.get("area") in AREAS else 99, m.get("nombre", "")))
# logins que no son miembros (cuentas viejas): se listan aparte para que el admin los vea
sueltos = sorted(u for u in logins if u not in {m["id"].lower() for m in _listar_json(EQUIPO_DIR)})
return jsonify(ok=True, total=len(out), miembros=out, areas=AREAS, admins=sorted(ADMINS),
logins_sin_miembro=sueltos if is_admin() else [], htpasswd_ok=_leer_htpasswd() is not None,
pass_min=PASS_MIN, admin=is_admin(), user=current_user())
@app.post("/api/equipo")
def equipo_crear():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador da de alta miembros del equipo."), 403
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
t, err = _miembro_valido(d)
if err:
return jsonify(ok=False, error=err), 400
rid = _norm_id(d.get("id") or t["nombre"].split()[0])
if not rid:
return jsonify(ok=False, error="El id debe tener letras o números (p. ej. adriana)."), 400
if os.path.exists(_ruta_registro(EQUIPO_DIR, rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el miembro {rid}."), 409
t["id"] = rid
t.update({"creado_por": u, "creado_en": ahora(), "modificado_por": u, "modificado_en": ahora()})
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro creado"}]
_guardar_miembro(t)
t["tiene_login"] = rid in _logins()
t["es_admin"] = rid in ADMINS
return jsonify(ok=True, miembro=t), 201
@app.patch("/api/equipo/miembro")
def equipo_modificar():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador edita el equipo."), 403
u = quien()
if not u:
return _sin_usuario()
rid = _norm_id(request.args.get("id"))
base = _leer_json(_ruta_registro(EQUIPO_DIR, rid)) if rid else None
if not base:
return jsonify(ok=False, error=f"No existe el miembro {rid}."), 404
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
for k in ("id", "tiene_login", "es_admin", "pendientes", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _miembro_valido(d, base)
if err:
return jsonify(ok=False, error=err), 400
cambios = [f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}" for k in d if k in t and t.get(k) != base.get(k)]
if not cambios:
return jsonify(ok=True, miembro=t, sin_cambios=True)
_hist(t, u, "; ".join(cambios))
_guardar_miembro(t)
t["tiene_login"] = rid in _logins()
t["es_admin"] = rid in ADMINS
return jsonify(ok=True, miembro=t)
@app.post("/api/usuarios")
def usuarios_crear():
"""Alta de acceso al sitio (línea en el htpasswd, mismo hash $apr1$ que /api/password) + miembro del equipo."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador crea accesos."), 403
yo = quien()
if not yo:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
usuario = _texto(d.get("usuario"), 30).lower()
if not USUARIO_RE.match(usuario):
return jsonify(ok=False, error="El usuario debe ser corto, en minúsculas y sin espacios ni acentos (p. ej. adriana)."), 400
password = str(d.get("password_inicial") or "")
if len(password) < PASS_MIN:
return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400
m_in = {"nombre": d.get("nombre") or usuario, "rol": d.get("rol", ""), "area": d.get("area", "produccion"),
"correo": d.get("correo", ""), "telefono": d.get("telefono", ""), "activo": True}
existente = _leer_json(_ruta_registro(EQUIPO_DIR, usuario))
t, err = _miembro_valido({k: v for k, v in m_in.items() if existente is None or d.get(k) not in (None, "")}, existente)
if err:
return jsonify(ok=False, error=err), 400
with _candado_htpasswd():
lineas = _leer_htpasswd()
if lineas is None:
return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500
if any(l.split(":", 1)[0].strip().lower() == usuario for l in lineas if ":" in l):
return jsonify(ok=False, existe=True, error=f"El usuario {usuario} ya tiene acceso. Si olvidó su contraseña, restablécela en Mi cuenta."), 409
salt = "".join(ITOA64[b % 64] for b in os.urandom(8))
lineas = [l for l in lineas if l.strip()] + [usuario + ":" + apr1(password, salt)]
try:
_escribir_htpasswd(lineas)
except PermissionError:
return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. "
"Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500
except OSError as e:
return jsonify(ok=False, error="No se pudo guardar el acceso: " + str(e)), 500
t["id"] = usuario
if existente is None:
t.update({"creado_por": yo, "creado_en": ahora(), "historial": []})
t["activo"] = True
_hist(t, yo, "acceso al sitio creado")
_guardar_miembro(t)
_registrar_acceso(yo, "alta", usuario)
t["tiene_login"] = True
t["es_admin"] = usuario in ADMINS
return jsonify(ok=True, usuario=usuario, miembro=t, nuevo_miembro=existente is None), 201
@app.delete("/api/usuario")
def usuario_borrar():
"""Quita el acceso (línea del htpasswd) y desactiva al miembro. Nunca borra el miembro ni sus pendientes."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador quita accesos."), 403
yo = quien()
if not yo:
return _sin_usuario()
usuario = _texto(request.args.get("u"), 30).lower()
if not usuario:
return jsonify(ok=False, error="falta u"), 400
if usuario == yo.lower():
return jsonify(ok=False, error="No puedes quitarte el acceso a ti mismo."), 400
with _candado_htpasswd():
lineas = _leer_htpasswd()
if lineas is None:
return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500
presentes = {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l}
if usuario not in presentes:
return jsonify(ok=False, error=f"El usuario {usuario} no tiene acceso."), 404
if usuario in ADMINS and len(presentes & ADMINS) <= 1:
return jsonify(ok=False, error="Es el último administrador con acceso; no se puede quitar."), 409
restantes = [l for l in lineas if not (":" in l and l.split(":", 1)[0].strip().lower() == usuario)]
try:
_escribir_htpasswd(restantes)
except PermissionError:
return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. "
"Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500
except OSError as e:
return jsonify(ok=False, error="No se pudo guardar: " + str(e)), 500
m = _leer_json(_ruta_registro(EQUIPO_DIR, usuario))
if m:
m["activo"] = False
_hist(m, yo, "acceso al sitio retirado; miembro desactivado")
_guardar_miembro(m)
_registrar_acceso(yo, "baja", usuario)
return jsonify(ok=True, usuario=usuario, miembro_desactivado=bool(m))
# ------------------------------------------------------------------ tareas (tablero)
COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "fecha_limite", "version_tarjeta",
COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite", "version_tarjeta",
"orden", "etiquetas", "bloqueada_por", "creado_por", "creado_en", "modificado_por", "modificado_en"]
def _ids_responsable(texto, miembros=None):
"""'leo, Jesús / claude' → (['leo','jesus','claude'], []). Partes que no son miembros se devuelven
como texto libre (segunda lista) para avisar; no se rechazan."""
miembros = _miembros() if miembros is None else miembros
ids, libres = [], []
for parte in re.split(r"[,;/]+", str(texto or "")):
parte = parte.strip()
if not parte:
continue
tokens = [_norm_id(x) for x in parte.split()]
if tokens and all(x in miembros for x in tokens):
ids.extend(x for x in tokens if x not in ids)
elif _norm_id(parte.replace(" ", "")) in miembros:
rid = _norm_id(parte.replace(" ", ""))
if rid not in ids:
ids.append(rid)
else:
libres.append(parte)
return ids, libres
def _tarea_valida(d, base=None):
"""Normaliza los campos de una tarjeta del tablero. Devuelve (tarea, error).
Con base=None es un alta (todos los campos con su valor por defecto); con base es un PATCH
@ -916,7 +1217,20 @@ def _tarea_valida(d, base=None):
return None, f"Estado inválido: {e}. Usa uno de {', '.join(ESTADOS)}."
pon("estado", e)
if alta or "responsable" in d:
pon("responsable", _texto(d.get("responsable"), 80))
# Obligatorio: ids de miembros del equipo separados por coma/espacio (o texto libre si la
# persona aún no está dada de alta; se acepta y se avisa). 'Jesús' ≡ 'jesus'.
texto = _texto(d.get("responsable") if not isinstance(d.get("responsable"), list)
else ", ".join(str(x) for x in d.get("responsable")), 120)
if not texto:
return None, "Falta el responsable del pendiente. Elige a alguien del equipo."
ids, libres = _ids_responsable(texto)
pon("responsable", ", ".join(ids + libres))
pon("responsable_ids", ids)
if libres:
t["_aviso"] = ("Responsable(s) sin registrar en el equipo: " + ", ".join(libres)
+ ". Se guarda como texto; da de alta a la persona en Equipo para que cuente en su lista.")
elif base is not None and "responsable_ids" not in t:
t["responsable_ids"] = _ids_responsable(t.get("responsable"))[0] # pendientes de antes del 14-oct
if alta or "fecha_limite" in d:
f = d.get("fecha_limite")
if f not in (None, "") and not _fecha_o_none(f):
@ -953,15 +1267,19 @@ def tareas_listar():
pista = _texto(request.args.get("pista"), 20).lower()
estado = _texto(request.args.get("estado"), 20).lower()
resp = _texto(request.args.get("responsable"), 80).lower()
resp_id = _norm_id(resp)
version = _texto(request.args.get("version"), 10)
hoy = datetime.date.today().isoformat()
miembros = _miembros()
out = []
for t in _listar_json(TAREAS_DIR):
if "responsable_ids" not in t: # pendientes guardados antes del 14-oct
t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0]
if pista and t.get("pista") != pista:
continue
if estado and t.get("estado") != estado:
continue
if resp and resp not in str(t.get("responsable", "")).lower():
if resp and not (resp_id in t["responsable_ids"] or resp in str(t.get("responsable", "")).lower()):
continue
if version and t.get("version_tarjeta") != version:
continue
@ -969,7 +1287,8 @@ def tareas_listar():
out.append(t)
out.sort(key=lambda t: (ESTADOS.index(t.get("estado")) if t.get("estado") in ESTADOS else 9,
t.get("orden", 0), t.get("creado_en", "")))
return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS,
equipo = {k: {"nombre": m.get("nombre"), "activo": m.get("activo", True), "area": m.get("area")} for k, m in miembros.items()}
return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS, equipo=equipo,
versiones=VERSIONES_TARJETA, admin=is_admin(), user=current_user())
@ -997,9 +1316,10 @@ def tareas_crear():
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "creado en " + t["estado"]}]
aviso = t.pop("_aviso", None)
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t), 201
return jsonify(ok=True, tarea=t, aviso=aviso), 201
@app.patch("/api/tarea")
@ -1023,6 +1343,7 @@ def tarea_modificar():
t, err = _tarea_valida(d, base)
if err:
return jsonify(ok=False, error=err), 400
aviso = t.pop("_aviso", None)
cambios = []
for k in d:
if k in t and t.get(k) != base.get(k):
@ -1031,19 +1352,19 @@ def tarea_modificar():
cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}")
nota = _texto(d.get("_nota"), 300)
if not cambios and not nota:
if t.get("orden") != base.get("orden"):
# solo cambió la posición: se guarda sin entrada en el historial
if t.get("orden") != base.get("orden") or t.get("responsable_ids") != base.get("responsable_ids"):
# solo cambió la posición (o se completaron los ids): se guarda sin entrada en el historial
t["modificado_por"] = u
t["modificado_en"] = ahora()
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t)
return jsonify(ok=True, tarea=t, aviso=aviso)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, sin_cambios=True)
return jsonify(ok=True, tarea=t, sin_cambios=True, aviso=aviso)
_hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota)
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t)
return jsonify(ok=True, tarea=t, aviso=aviso)
@app.post("/api/tareas/orden")
@ -1099,15 +1420,30 @@ def tareas_resumen():
conteo = {p: {e: 0 for e in ESTADOS} for p in PISTAS}
vencidas = []
total = 0
miembros = _miembros()
por_resp = {}
for t in _listar_json(TAREAS_DIR):
total += 1
p, e = t.get("pista"), t.get("estado")
if p in conteo and e in conteo[p]:
conteo[p][e] += 1
ids = t.get("responsable_ids")
if ids is None:
ids = _ids_responsable(t.get("responsable"), miembros)[0]
for rid in (ids or ["_sin_equipo"]):
r = por_resp.setdefault(rid, _cuenta_vacia())
if e in ESTADOS:
r[e] += 1
if e != "hecho":
r["abiertos"] += 1
if _vencida(t, hoy):
vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "fecha_limite")})
r["vencidas"] += 1
if _vencida(t, hoy):
vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite")})
vencidas.sort(key=lambda x: x.get("fecha_limite") or "")
return jsonify(ok=True, total=total, hoy=hoy, por_pista=conteo, vencidas=vencidas)
for rid, r in por_resp.items():
r["nombre"] = miembros.get(rid, {}).get("nombre") or ("(sin miembro del equipo)" if rid == "_sin_equipo" else rid)
return jsonify(ok=True, total=total, hoy=hoy, por_pista=conteo, por_responsable=por_resp, vencidas=vencidas)
# ------------------------------------------------------------------ plan de producción

View File

@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario
y los pendientes del tablero (brief §4). Idempotente: lo que ya existe (mismo id) se deja como está.
"""Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario,
el equipo (14-oct) y los pendientes del tablero (brief §4). Idempotente: lo que ya existe (mismo id)
se deja como está; en pendientes viejos solo se completa el responsable si estaba vacío y se calcula
responsable_ids.
Uso (en el servidor, como el usuario del servicio, normalmente leo):
cd /srv/projects/drogheda/hbox-docs
@ -31,6 +33,19 @@ def marca(u):
# ------------------------------------------------------------------ datos
# Miembros del equipo (14-oct). NO crea accesos al sitio: eso se hace desde Equipo (admin) o /api/usuarios.
EQUIPO = [
("leo", "Leopoldo Rodríguez", "Dirección / ingeniería", "direccion", ""),
("adriana", "Adriana", "Operaciones; lleva el PM del proyecto", "operaciones", ""),
("michelle", "Michelle Márquez", "Layout de PCB", "layout", ""),
("jesus", "Jesús", "Técnico de pruebas de tarjetas", "pruebas", ""),
("alan", "Alán Basurto", "Técnico", "produccion", ""),
("daniel", "Daniel Martínez", "Técnico", "produccion", ""),
("aldair", "Aldair", "Fabricación / ensamble de tarjetas (rol por confirmar)", "fabricacion", ""),
("gibran", "Gibran", "Software htracker", "software", ""),
("claude", "Claude", "Asistente de ingeniería", "asistente", ""),
]
SITIOS = [
{"id": "icave", "nombre": "ICAVE", "cliente": "Hutchison Ports", "ubicacion": "Veracruz, Ver.",
"alias": ["ICAVE (Veracruz)", "Oficina ICAVE"], "orden": 10,
@ -99,7 +114,7 @@ TAREAS = [
"enlaces": [{"titulo": "Próximos pasos v6", "url": "/hardware/v6-proximos-pasos/"},
{"titulo": "Plan de corrección", "url": "/hardware/v6-plan-correccion/"}],
"descripcion": "Aplicar a P7/P8 lo decidido para P12 en V5.6; regla DFM U6; no habrá v6.1.", "etiquetas": ["fabricación"]},
{"id": "t-caja-ethernet", "pista": "mecanica", "estado": "bloqueado", "responsable": "", "version_tarjeta": "—",
{"id": "t-caja-ethernet", "pista": "mecanica", "estado": "bloqueado", "responsable": "leo", "version_tarjeta": "—",
"titulo": "Caja plástica HBOX — el cable Ethernet no cabe: definir y aplicar el cambio",
"descripcion": "Falta definir la causa exacta (largo del boot RJ45, tamaño de la abertura o radio de curvatura) y quién lo corrige "
"(pieza impresa en 3D o proveedor). Al definirlo: escribir el procedimiento y mover a En curso.",
@ -128,8 +143,16 @@ TAREAS = [
"titulo": "Acta de instalación en campo: formulario nuevo + instrucciones",
"enlaces": [{"titulo": "Acta de instalación", "url": "/formularios/instalacion.html"}],
"descripcion": "Formulario publicado el 13-oct; falta probarlo en una instalación real y ajustar el checklist."},
{"id": "t-ferreteria-eit-timsa", "pista": "compras", "estado": "por-hacer", "responsable": "adriana", "version_tarjeta": "—",
"fecha_limite": "2026-10-14",
"titulo": "Cerrar compras de ferretería para EIT y TIMSA",
"descripcion": "Material de ferretería para la instalación de gabinetes en EIT (Ensenada) y TIMSA (Manzanillo). Cerrar pedidos mañana.",
"enlaces": [{"titulo": "Sitios", "url": "/sitios/"}], "etiquetas": ["instalación"]},
]
# Responsable por omisión para pendientes ya sembrados que quedaron sin responsable (solo si está vacío).
RESPONSABLE_SI_VACIO = {"t-caja-ethernet": "leo"}
# ------------------------------------------------------------------ siembra
@ -180,7 +203,45 @@ def sembrar_inventario(u):
return f"inventario: {n} ítems creados, {len(INVENTARIO) - n} ya existían (stock 0: falta el conteo físico)"
def sembrar_tareas(u, lista, origen="seed_1013"):
def sembrar_equipo(u):
n = 0
for rid, nombre, rol, area, correo in EQUIPO:
if os.path.isfile(backend._ruta_registro(backend.EQUIPO_DIR, rid)):
continue
t, err = backend._miembro_valido({"nombre": nombre, "rol": rol, "area": area, "correo": correo, "activo": True})
if err:
print(" miembro", rid, "->", err); continue
t["id"] = rid
t.update(marca(u))
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro sembrado (seed_1013)"}]
backend._guardar_miembro(t)
n += 1
return f"equipo: {n} miembros creados, {len(EQUIPO) - n} ya existían (sin crear accesos)"
def completar_responsables(u):
"""Pendientes ya guardados: (a) responsable por omisión SOLO si está vacío; (b) responsable_ids
calculado si falta (pendientes de antes del 14-oct). No toca lo que la gente ya cambió."""
n_resp, n_ids = 0, 0
miembros = backend._miembros()
for t in backend._listar_json(backend.TAREAS_DIR):
cambio = False
if not str(t.get("responsable") or "").strip() and t.get("id") in RESPONSABLE_SI_VACIO:
t["responsable"] = RESPONSABLE_SI_VACIO[t["id"]]
backend._hist(t, u, "responsable: → " + t["responsable"] + " (seed_1013, estaba vacío)")
n_resp += 1; cambio = True
ids = backend._ids_responsable(t.get("responsable"), miembros)[0]
if t.get("responsable_ids") != ids:
t["responsable_ids"] = ids
n_ids += 1; cambio = True
if cambio:
backend._escribir_json(backend._ruta_registro(backend.TAREAS_DIR, t["id"]), t)
if n_resp or n_ids:
backend._reescribir_indice(backend.TAREAS_DIR, backend._listar_json(backend.TAREAS_DIR), backend.COLS_TAREAS)
return f"pendientes existentes: {n_resp} responsables completados, {n_ids} con responsable_ids recalculado"
def sembrar_tareas(u, lista, origen="seed_1013", responsable_omision="leo"):
n, orden = 0, {}
existentes = backend._listar_json(backend.TAREAS_DIR)
for t in existentes:
@ -191,6 +252,9 @@ def sembrar_tareas(u, lista, origen="seed_1013"):
print(" pendiente sin id, se omite:", d.get("titulo")); continue
if os.path.isfile(backend._ruta_registro(backend.TAREAS_DIR, rid)):
continue
if not str(d.get("responsable") or "").strip():
d = dict(d, responsable=responsable_omision) # un pendiente siempre tiene responsable (14-oct)
print(f" pendiente {rid}: sin responsable en el origen → {responsable_omision}")
t, err = backend._tarea_valida(d)
if err:
print(" pendiente", rid, "->", err); continue
@ -208,14 +272,15 @@ def sembrar_tareas(u, lista, origen="seed_1013"):
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--usuario", default="leo", help="quién firma la siembra en los campos de auditoría")
ap.add_argument("--solo", default="plan,sitios,inventario,tareas", help="partes a sembrar, separadas por coma")
ap.add_argument("--solo", default="plan,sitios,inventario,equipo,tareas", help="partes a sembrar, separadas por coma")
ap.add_argument("--extra", help="JSON con una lista de pendientes adicionales (p. ej. importación de Notion)")
ap.add_argument("--forzar-plan", action="store_true", help="reescribe el plan aunque ya exista")
ap.add_argument("--responsable-omision", default="leo", help="responsable para pendientes importados sin uno (--extra)")
a = ap.parse_args()
partes = {p.strip() for p in a.solo.split(",")}
print("Carpetas:", {"tareas": backend.TAREAS_DIR, "inventario": backend.INVENTARIO_DIR,
"sitios": backend.SITIOS_DIR, "plan": backend.PLAN_FILE})
for d in (backend.TAREAS_DIR, backend.INV_ITEMS(), backend.INV_MOVS(), backend.SITIOS_DIR,
for d in (backend.TAREAS_DIR, backend.INV_ITEMS(), backend.INV_MOVS(), backend.SITIOS_DIR, backend.EQUIPO_DIR,
os.path.dirname(backend.PLAN_FILE)):
os.makedirs(d, exist_ok=True)
if "plan" in partes:
@ -224,14 +289,17 @@ def main():
print(sembrar_sitios(a.usuario))
if "inventario" in partes:
print(sembrar_inventario(a.usuario))
if "equipo" in partes:
print(sembrar_equipo(a.usuario)) # antes que los pendientes: así responsable_ids sale completo
if "tareas" in partes:
print(sembrar_tareas(a.usuario, TAREAS))
print(completar_responsables(a.usuario))
if a.extra:
with open(a.extra, encoding="utf-8") as f:
extra = json.load(f)
if not isinstance(extra, list):
sys.exit("--extra debe ser una lista JSON de pendientes")
print(sembrar_tareas(a.usuario, extra, origen=os.path.basename(a.extra)))
print(sembrar_tareas(a.usuario, extra, origen=os.path.basename(a.extra), responsable_omision=a.responsable_omision))
print("Listo.")

View File

@ -22,10 +22,10 @@ check "actas vacías" '"total":0' "$(curl -s $U/api/actas)"
echo "## tareas"
check "sin usuario → 403" "No puedo identificarte" "$(curl -s -H 'Content-Type: application/json' -X POST $U/api/tareas -d '{"titulo":"x"}')"
check "POST (alan)" '"id":"t-a"' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"Pendiente A","pista":"ingenieria","responsable":"michelle","fecha_limite":"2026-10-01","version_tarjeta":"V5.6","enlaces":[{"titulo":"ECO","url":"/hardware/v56-eco/"}]}')"
check "POST id duplicado → 409" '"existe":true' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"dup"}')"
check "POST pista inválida → 400" "Pista inv" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","pista":"ventas"}')"
check "POST fecha inválida → 400" "AAAA-MM-DD" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","fecha_limite":"13/10/2026"}')"
curl "${LEO[@]}" -X POST $U/api/tareas -d '{"id":"t-b","titulo":"Pendiente B","pista":"compras","bloqueada_por":["t-a"]}' > /dev/null
check "POST id duplicado → 409" '"existe":true' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"dup","responsable":"leo"}')"
check "POST pista inválida → 400" "Pista inv" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","pista":"ventas","responsable":"leo"}')"
check "POST fecha inválida → 400" "AAAA-MM-DD" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","fecha_limite":"13/10/2026","responsable":"leo"}')"
curl "${LEO[@]}" -X POST $U/api/tareas -d '{"id":"t-b","titulo":"Pendiente B","pista":"compras","responsable":"leo","bloqueada_por":["t-a"]}' > /dev/null
check "GET filtro pista" '"total":1' "$(curl -s "$U/api/tareas?pista=compras")"
check "GET filtro responsable" '"total":1' "$(curl -s "$U/api/tareas?responsable=mich")"
check "vencida calculada" '"vencida":true' "$(curl -s "$U/api/tareas?pista=ingenieria")"
@ -87,14 +87,14 @@ check "/api/actas trae sitio" '"sitio":"icave"' "$(curl -s "$U/api/actas")"
echo "## siembra (idempotente)"
S1=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
check "primera corrida crea" "pendientes (seed_1013): 12 creados" "$S1"
check "primera corrida crea" "pendientes (seed_1013): 13 creados" "$S1"
check "sitios: 3 nuevos (icave ya existía)" "sitios: 3 creados, 1 ya existían" "$S1"
S2=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
check "segunda corrida no duplica" "12 ya existían" "$S2"
check "segunda corrida no duplica" "13 ya existían" "$S2"
check "inventario no duplica" "0 ítems creados" "$S2"
echo '[{"id":"t-notion-1","titulo":"Importado de Notion","pista":"ingenieria","estado":"hecho"}]' > $T/extra.json
echo '[{"id":"t-notion-1","titulo":"Importado de Notion","pista":"ingenieria","estado":"hecho"}]' > $T/extra.json # sin responsable: el seed pone el de omisión (leo)
check "--extra importa" "extra.json): 1 creados" "$(python3 $REPO/backend/seed_1013.py --solo nada --extra $T/extra.json 2>&1)"
check "resumen tras siembra (1 + 12 sembrados + 1 extra)" '"total":14' "$(curl -s $U/api/tareas/resumen)"
check "resumen tras siembra (1 + 13 sembrados + 1 extra)" '"total":15' "$(curl -s $U/api/tareas/resumen)"
echo; echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
[ $fail -eq 0 ]

106
backend/tests-1014.sh Normal file
View File

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Pruebas del 14-oct: equipo, accesos (htpasswd temporal), responsable obligatorio, resumen por responsable,
# siembra idempotente con el equipo. Complementa a tests-1013.sh (que sigue valiendo tal cual).
# Uso: bash tests-1014.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8097}
T=$(mktemp -d); export ACTAS_DIR=$T/actas PORT HTPASSWD_FILE=$T/htpasswd ACTAS_ADMINS=leo,alan
mkdir -p $ACTAS_DIR
# htpasswd de prueba con el mismo hash $apr1$ que escribe el backend (leo y bob; alan es admin SIN login todavía)
python3 - "$REPO" "$T/htpasswd" <<'EOF'
import sys; sys.path.insert(0, sys.argv[1] + "/backend"); import app
open(sys.argv[2], "w").write("leo:" + app.apr1("secreto123", "abcdefgh") + "\nbob:" + app.apr1("secreto123", "hgfedcba") + "\n")
EOF
chmod 640 $T/htpasswd
python3 $REPO/backend/app.py > $T/app.log 2>&1 &
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.5
U="http://127.0.0.1:$PORT"
LEO=(-s -H "X-Remote-User: leo" -H "Content-Type: application/json")
ALAN=(-s -H "X-Remote-User: alan" -H "Content-Type: application/json")
BOB=(-s -H "X-Remote-User: bob" -H "Content-Type: application/json")
ok=0; fail=0
check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:160}"; fi; }
echo "## siembra (incluye equipo) — con un pendiente viejo sin responsable y sin responsable_ids"
mkdir -p $T/tareas
cat > $T/tareas/t-caja-ethernet.json <<'EOF'
{"id":"t-caja-ethernet","titulo":"Caja plástica HBOX — viejo","pista":"mecanica","estado":"bloqueado","responsable":"","fecha_limite":null,
"enlaces":[],"bloqueada_por":[],"orden":0,"etiquetas":[],"version_tarjeta":"—","creado_por":"leo","creado_en":"2026-10-13T20:00:00",
"modificado_por":"leo","modificado_en":"2026-10-13T20:00:00","historial":[{"quien":"leo","cuando":"2026-10-13T20:00:00","cambio":"creado en bloqueado"}]}
EOF
S1=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
check "equipo sembrado (9)" "equipo: 9 miembros creados" "$S1"
check "pendientes nuevos (12 de 13: t-caja-ethernet ya existía)" "pendientes (seed_1013): 12 creados" "$S1"
check "responsable vacío completado a leo" "1 responsables completados" "$S1"
check "responsable_ids recalculado" "con responsable_ids recalculado" "$S1"
check "t-caja-ethernet → leo con historial" '"responsable": "leo"' "$(python3 -c "import json;print(json.dumps(json.load(open('$T/tareas/t-caja-ethernet.json')),indent=1))")"
S2=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
check "segunda corrida: equipo no duplica" "equipo: 0 miembros creados, 9 ya existían" "$S2"
check "segunda corrida: nada que completar" "0 responsables completados, 0 con responsable_ids" "$S2"
check "t-ferreteria-eit-timsa sembrada para adriana" '"responsable_ids":["adriana"]' "$(curl -s "$U/api/tareas?responsable=adriana")"
echo "## equipo"
R=$(curl "${BOB[@]}" $U/api/equipo)
check "GET equipo (cualquiera)" '"total":9' "$R"
check "tiene_login derivado del htpasswd (leo sí)" '"id": "leo"' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m for m in json.load(sys.stdin)["miembros"] if m["tiene_login"]]))')"
check "adriana sin login" '"tiene_login": false' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m for m in json.load(sys.stdin)["miembros"] if m["id"]=="adriana"]))')"
check "es_admin desde ACTAS_ADMINS" '"es_admin": true' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m for m in json.load(sys.stdin)["miembros"] if m["id"]=="alan"]))')"
check "pendientes abiertos por miembro" '"abiertos": 1' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m["pendientes"] for m in json.load(sys.stdin)["miembros"] if m["id"]=="adriana"]))')"
check "logins sin miembro solo para admin (bob no admin → [])" '"logins_sin_miembro":[]' "$R"
check "logins sin miembro para admin (bob)" '"logins_sin_miembro":["bob"]' "$(curl "${LEO[@]}" $U/api/equipo)"
check "POST equipo no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X POST $U/api/equipo -d '{"id":"x","nombre":"X"}')"
check "POST equipo (admin)" '"id":"carla"' "$(curl "${LEO[@]}" -X POST $U/api/equipo -d '{"id":"Carla","nombre":"Carla Ruiz","rol":"Compras","area":"operaciones"}')"
check "POST equipo duplicado → 409" '"existe":true' "$(curl "${LEO[@]}" -X POST $U/api/equipo -d '{"id":"carla","nombre":"dup"}')"
check "POST equipo área inválida → 400" "inv\\u00e1lida: ventas" "$(curl "${LEO[@]}" -X POST $U/api/equipo -d '{"id":"z","nombre":"Z","area":"ventas"}')"
check "PATCH miembro no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X PATCH "$U/api/equipo/miembro?id=carla" -d '{"rol":"x"}')"
check "PATCH miembro (admin) → historial" "rol: Compras" "$(curl "${LEO[@]}" -X PATCH "$U/api/equipo/miembro?id=carla" -d '{"rol":"Compras y logística"}')"
check "PATCH activo=false (desactivar, nunca borrar)" '"activo":false' "$(curl "${LEO[@]}" -X PATCH "$U/api/equipo/miembro?id=carla" -d '{"activo":false}')"
check "GET equipo oculta inactivos" '"total":9' "$(curl -s $U/api/equipo)"
check "GET equipo?todos=1 los muestra" '"total":10' "$(curl -s "$U/api/equipo?todos=1")"
check "PATCH inexistente → 404" "No existe el miembro" "$(curl "${LEO[@]}" -X PATCH "$U/api/equipo/miembro?id=nadie" -d '{"rol":"x"}')"
echo "## accesos (htpasswd temporal)"
check "POST usuarios no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X POST $U/api/usuarios -d '{"usuario":"x","password_inicial":"12345678"}')"
check "contraseña corta → 400" "al menos 8" "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"adriana","password_inicial":"corta"}')"
check "usuario con espacios/mayúsculas → 400" "sin espacios ni acentos" "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"Adri ana","password_inicial":"12345678"}')"
check "crea acceso a miembro existente (adriana)" '"nuevo_miembro":false' "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"adriana","password_inicial":"operaciones2026","correo":"adriana@helgen.tech"}')"
check "línea en el htpasswd con apr1" 'adriana:$apr1$' "$(cat $T/htpasswd)"
check "el hash valida con apr1 (misma rutina que /api/password)" "True" "$(python3 - "$REPO" "$T/htpasswd" <<'EOF'
import sys; sys.path.insert(0, sys.argv[1] + "/backend"); import app
l=[x for x in open(sys.argv[2]).read().splitlines() if x.startswith("adriana:")][0].split(":",1)[1]
print(app.apr1("operaciones2026", l.split("$")[2]) == l)
EOF
)"
check "permisos del archivo conservados (640)" "640" "$(stat -c %a $T/htpasswd)"
check "duplicado → 409" '"existe":true' "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"adriana","password_inicial":"operaciones2026"}')"
check "crea usuario Y miembro nuevos" '"nuevo_miembro":true' "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"nuevo1","password_inicial":"nuevo1pass","nombre":"Nuevo Uno","area":"campo"}')"
check "GET /api/usuarios sigue igual (lista del htpasswd)" '"usuarios":["adriana","bob","leo","nuevo1"]' "$(curl "${LEO[@]}" $U/api/usuarios)"
check "la cuenta nueva cambia su propia contraseña por /api/password" '"reset":false' "$(curl -s -H 'X-Remote-User: nuevo1' -H 'Content-Type: application/json' -X POST $U/api/password -d '{"actual":"nuevo1pass","nueva":"otraclave2026","confirma":"otraclave2026"}')"
check "bitácora _accesos.csv" "leo,alta,adriana" "$(cat $T/equipo/_accesos.csv)"
check "DELETE no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X DELETE "$U/api/usuario?u=nuevo1")"
check "DELETE a sí mismo → 400" "a ti mismo" "$(curl "${LEO[@]}" -X DELETE "$U/api/usuario?u=leo")"
check "DELETE inexistente → 404" "no tiene acceso" "$(curl "${LEO[@]}" -X DELETE "$U/api/usuario?u=nadie")"
check "último admin con acceso (alan admin sin login quita a leo) → 409" "ltimo administrador" "$(curl "${ALAN[@]}" -X DELETE "$U/api/usuario?u=leo")"
curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"alan","password_inicial":"alanclave2026"}' > /dev/null
check "con dos admins con acceso, alan sí puede quitar a leo" '"usuario":"leo"' "$(curl "${ALAN[@]}" -X DELETE "$U/api/usuario?u=leo")"
check "leo ya no está en el htpasswd" "" "$(grep -c '^leo:' $T/htpasswd | sed 's/^0$//')"
check "miembro leo desactivado (no borrado)" '"activo": false' "$(python3 -c "import json;print(json.dumps(json.load(open('$T/equipo/leo.json')),indent=1))")"
check "DELETE nuevo1 (admin alan)" '"miembro_desactivado":true' "$(curl "${ALAN[@]}" -X DELETE "$U/api/usuario?u=nuevo1")"
echo "## responsable obligatorio y resumen"
check "crear sin responsable → 400" "Falta el responsable" "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"titulo":"x"}')"
check "crear con ids (y 'Jesús' con acento → jesus)" '"responsable_ids":["michelle","jesus"]' "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x1","titulo":"x","responsable":"Michelle, Jesús"}')"
check "texto libre → se acepta con aviso" "sin registrar en el equipo: Proveedor X" "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x2","titulo":"y","responsable":"leo / Proveedor X"}')"
check "lista como array también vale" '"responsable":"alan, daniel"' "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x3","titulo":"z","responsable":["alan","daniel"]}')"
check "PATCH responsable vacío → 400" "Falta el responsable" "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"responsable":""}')"
check "PATCH sin tocar responsable sigue funcionando" '"estado":"en-curso"' "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"estado":"en-curso"}')"
check "filtro por id" '"id":"t-x1"' "$(curl -s "$U/api/tareas?responsable=jesus")"
check "filtro con acento" '"id":"t-x1"' "$(curl -s "$U/api/tareas?responsable=jes%C3%BAs")"
check "GET tareas trae el mapa equipo" '"michelle":{' "$(curl -s "$U/api/tareas?responsable=michelle" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["equipo"], separators=(",",":")))')"
R=$(curl -s $U/api/tareas/resumen)
check "resumen por_responsable (jesus)" '"nombre": "Jes' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["por_responsable"]["jesus"], indent=1))')"
check "resumen por_responsable cuenta abiertos" '"abiertos": 3' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["por_responsable"]["jesus"], indent=1))')"
check "resumen conserva por_pista" '"por_pista"' "$R"
echo; echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
[ $fail -eq 0 ]

View File

@ -18,7 +18,7 @@
<body>
<div class="wrap">
<header class="top"><h1>Administración de actas</h1><p id="sub">Solo para el administrador.</p></header>
<div style="margin:2px 0 10px"><a class="volver" href="./tablero.html">← Tablero</a></div>
<div style="margin:2px 0 10px;display:flex;gap:16px;flex-wrap:wrap"><a class="volver" href="./tablero.html">← Tablero</a><a class="volver" href="tableros/equipo.html">Usuarios y equipo →</a></div>
<div id="cont"><p>Comprobando permisos…</p></div>
</div>
<script>

View File

@ -72,6 +72,7 @@
<h2>Si olvidaste tu contraseña</h2>
<p class="sub" style="margin:0">
No hay recuperación por correo: pídele a Leo que te la restablezca desde esta misma página.
Si todavía no tienes acceso, pídeselo a Leo (Administración → <a href="tableros/equipo.html">Usuarios y equipo</a>).
</p>
</section>
</div>

226
docs/tableros/equipo.html Normal file
View File

@ -0,0 +1,226 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Equipo y accesos — HBOX</title>
<link rel="stylesheet" href="tablero.css">
<style>
table.t td.num{text-align:right;font-variant-numeric:tabular-nums}
table.t th.num{text-align:right}
table.t tr.inactivo td{opacity:.55}
table.t td .nom{font-weight:700;cursor:pointer;color:var(--naranja)}
table.t td .nom:hover{text-decoration:underline}
table.t td .id{font-size:11.5px;color:var(--tinta2);display:block}
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
.area{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:99px;background:var(--pend-bg);color:var(--tinta2)}
.pass-una-vez{background:var(--aviso-bg);border:1px solid var(--aviso);border-radius:10px;padding:12px 14px;margin-top:10px;font-size:14.5px;color:#7a4a00}
.pass-una-vez code{font-size:17px;font-weight:700;letter-spacing:.04em;user-select:all}
.mis-pend .card{cursor:default}
@media(max-width:760px){#cont table.t{min-width:760px}}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Equipo y accesos</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="kanban.html">Pendientes</a>
<a href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a class="activo" href="equipo.html">Equipo</a>
<a href="../cuenta.html">Mi cuenta</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Aquí está <b>quién es quién</b>: nombre, rol, área, si tiene acceso al sitio y cuántos pendientes abiertos y vencidos trae cada quien.</li>
<li><b>Todo pendiente tiene responsable.</b> Al crearlo en el tablero se elige de esta lista; puede haber más de uno.</li>
<li>Un miembro <b>sin acceso al sitio</b> también puede ser responsable (Adriana hoy): el pendiente se le asigna igual y alguien con acceso lo mueve por ella.</li>
<li>Toca un nombre para ver sus pendientes abiertos y abrir el tablero filtrado.</li>
<li>Los <b>accesos los crea Leo aquí</b> (botón «Nuevo usuario / miembro»), ya no con <code>htpasswd</code> en el servidor. La contraseña inicial se muestra <b>una sola vez</b>: pásala en persona o por mensaje directo; la persona la cambia en <a href="../cuenta.html">Mi cuenta</a>.</li>
<li>«Desactivar» oculta al miembro de las listas sin borrar nada; «Quitar acceso» le cierra la entrada al sitio y lo desactiva. Nadie se borra: el historial se queda.</li>
<li>Si no tienes acceso o no apareces, pídeselo a Leo.</li>
</ol>
</details>
<div class="msg" id="aviso"></div>
<div id="acciones" style="display:none;margin:0 0 12px"><button id="bNuevo">+ Nuevo usuario / miembro</button></div>
<div id="cont" class="tblwrap"></div>
<section class="bloque mis-pend" id="detalle" style="display:none"></section>
</div>
<dialog id="dlg"></dialog>
<script src="kanban.js"></script>
<script>
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const $ = id => document.getElementById(id);
const AREA = { direccion: 'Dirección', operaciones: 'Operaciones', ingenieria: 'Ingeniería', layout: 'Layout', produccion: 'Producción', pruebas: 'Pruebas', fabricacion: 'Fabricación', software: 'Software', campo: 'Campo', asistente: 'Asistente' };
const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS);
let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true;
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.innerHTML = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 5000 : 10000); }
async function api(url, opts) { const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {})); let j = null; try { j = await r.json(); } catch (e) {} if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; }
const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text: l }), i); if (ayuda) d.append(el('div', { style: 'font-size:12.5px;color:var(--tinta2);margin-top:3px', text: ayuda })); return d; }
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
if (!MIEMBROS.length) { cont.append(el('div', { class: 'vacio', text: 'Todavía no hay miembros. Corre la siembra o crea el primero.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Nombre</th><th>Rol</th><th>Área</th><th>Acceso</th><th>Admin</th><th class="num">Abiertos</th><th class="num">Vencidos</th><th>Activo</th>' + (ADMIN ? '<th></th>' : '') + '</tr>';
MIEMBROS.forEach(m => {
const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' });
const nom = el('span', { class: 'nom', text: m.nombre }); nom.addEventListener('click', () => detalle(m));
tr.append(el('td', null, nom, el('span', { class: 'id', text: m.id + (m.correo ? ' · ' + m.correo : '') + (m.telefono ? ' · ' + m.telefono : '') })));
tr.append(el('td', { text: m.rol || '—' }));
tr.append(el('td', null, el('span', { class: 'area', text: AREA[m.area] || m.area })));
tr.append(el('td', { html: m.tiene_login ? '<span class="pill ok">sí</span>' : '<span class="pill pend">no</span>' }));
tr.append(el('td', { html: m.es_admin ? '<span class="pill info">admin</span>' : '' }));
const p = m.pendientes || {};
tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '"><b>' + p.abiertos + '</b></a>' : '0' }));
tr.append(el('td', { class: 'num', html: p.vencidas ? '<span style="color:var(--falla);font-weight:700">' + p.vencidas + '</span>' : '0' }));
tr.append(el('td', { html: m.activo === false ? '<span class="pill falla">inactivo</span>' : '<span class="pill ok">activo</span>' }));
if (ADMIN) {
const acc = el('td', { style: 'white-space:nowrap' });
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be);
if (m.activo !== false) { const bd = el('button', { class: 'sec', text: 'Desactivar' }); bd.addEventListener('click', () => desactivar(m)); acc.append(bd); }
else { const ba = el('button', { class: 'sec', text: 'Reactivar' }); ba.addEventListener('click', () => reactivar(m)); acc.append(ba); }
if (m.tiene_login && m.id.toLowerCase() !== USER.toLowerCase()) { const bq = el('button', { class: 'peligro', text: 'Quitar acceso' }); bq.addEventListener('click', () => quitarAcceso(m)); acc.append(bq); }
if (!m.tiene_login && m.activo !== false) { const bc = el('button', { text: 'Crear acceso' }); bc.addEventListener('click', () => nuevo(m)); acc.append(bc); }
tr.append(acc);
}
t.append(tr);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/equipo?todos=1');
MIEMBROS = j.miembros || []; ADMIN = !!j.admin; USER = j.user || ''; PASS_MIN = j.pass_min || 8; AREAS = j.areas || AREAS; HTPASSWD_OK = j.htpasswd_ok !== false;
Kanban.usarEquipo(Object.fromEntries(MIEMBROS.map(m => [m.id, { nombre: m.nombre, activo: m.activo !== false, area: m.area }])));
pintar();
$('acciones').style.display = ADMIN ? '' : 'none';
const act = MIEMBROS.filter(m => m.activo !== false), con = act.filter(m => m.tiene_login).length;
$('meta').textContent = act.length + ' miembros activos · ' + con + ' con acceso al sitio · ' + (USER ? USER + (ADMIN ? ' (admin)' : '') : '');
if (ADMIN && !HTPASSWD_OK) mensaje('El servicio no encuentra el archivo de usuarios (HTPASSWD_FILE): se pueden editar miembros, pero no crear ni quitar accesos. Avisa a Leo.', false);
if (ADMIN && (j.logins_sin_miembro || []).length) mensaje('Cuentas con acceso que no están en el equipo: <b>' + j.logins_sin_miembro.map(esc).join(', ') + '</b>. Dales de alta como miembro (Nuevo usuario / miembro, sin marcar «crear acceso») para que puedan ser responsables.', false);
const q = new URLSearchParams(location.search).get('id'); if (q) { const m = MIEMBROS.find(x => x.id === q); if (m) detalle(m); }
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el equipo: ' + e.message })); $('meta').textContent = 'Error'; }
}
// ---------- pendientes de un miembro ----------
async function detalle(m) {
const sec = $('detalle'); sec.style.display = ''; sec.innerHTML = '';
sec.append(el('h2', { text: 'Pendientes de ' + m.nombre }), el('p', { class: 'sub', html: esc(m.rol || '') + ' · ' + esc(AREA[m.area] || m.area) + ' · <a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '">Abrir en el tablero →</a>' }));
const lista = el('div', { text: 'Cargando…' }); sec.append(lista);
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const j = await api('/api/tareas?responsable=' + encodeURIComponent(m.id));
const abiertas = (j.tareas || []).filter(t => t.estado !== 'hecho');
lista.innerHTML = '';
if (!abiertas.length) { lista.append(el('div', { class: 'vacio', text: 'Sin pendientes abiertos.' })); return; }
abiertas.forEach(t => {
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : '') });
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span><span class="chip">' + esc(ESTADOS[t.estado] || t.estado) + '</span>'
+ (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">' + (t.vencida ? '⚠ venció ' : '📅 ') + esc(t.fecha_limite) + '</span>' : '')
+ ((t.responsable_ids || []).length > 1 ? '<span>👥 ' + esc(Kanban.nombresResp(t).join(', ')) + '</span>' : '') }));
lista.append(c);
});
} catch (e) { lista.textContent = 'No se pudieron leer los pendientes: ' + e.message; }
}
// ---------- diálogos (admin) ----------
function abrirDialogo(titulo, cuerpoFn, botones) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const m = el('div', { class: 'msg' });
cuerpoFn(cuerpo, m); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: botones.length ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
botones.forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m, b, pie); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function formMiembro(c, m, conAcceso) {
m = m || {}; const f = {};
f.id = el('input', { type: 'text', maxlength: '30', value: m.id || '', placeholder: 'adriana (minúsculas, sin acentos; será el usuario del sitio)', autocapitalize: 'none' }); if (m.id) f.id.disabled = true;
f.nombre = el('input', { type: 'text', maxlength: '80', value: m.nombre || '', placeholder: 'Nombre y apellido' });
f.rol = el('input', { type: 'text', maxlength: '120', value: m.rol || '', placeholder: 'Técnico de pruebas · Layout de PCB · Operaciones' });
f.area = el('select'); AREAS.forEach(a => f.area.append(el('option', { value: a, text: AREA[a] || a }))); f.area.value = m.area || 'produccion';
f.correo = el('input', { type: 'email', maxlength: '120', value: m.correo || '' });
f.telefono = el('input', { type: 'tel', maxlength: '40', value: m.telefono || '' });
const g = el('div', { class: 'dos-col' }); g.append(campo('Usuario (id)', f.id), campo('Nombre', f.nombre), campo('Rol', f.rol), campo('Área', f.area), campo('Correo', f.correo), campo('Teléfono', f.telefono)); c.append(g);
if (conAcceso) {
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK;
const l = el('label', { class: 'chip-sel', style: 'margin-top:12px' }); l.append(f.acceso, document.createTextNode(' Crear acceso al sitio (usuario y contraseña)')); c.append(l);
f.pass = el('input', { type: 'text', autocomplete: 'off', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
const cp = campo('Contraseña inicial', f.pass, 'La persona la cambia en Mi cuenta la primera vez que entre.'); c.append(cp);
const gen = el('button', { type: 'button', class: 'sec', text: 'Generar una', style: 'padding:6px 10px;font-size:13px' });
gen.addEventListener('click', () => { const A = 'abcdefghjkmnpqrstuvwxyz23456789'; let p = ''; const r = new Uint32Array(12); crypto.getRandomValues(r); r.forEach(v => p += A[v % A.length]); f.pass.value = p.slice(0, 4) + '-' + p.slice(4, 8) + '-' + p.slice(8, 12); });
cp.append(gen);
const sync = () => { cp.style.display = f.acceso.checked ? '' : 'none'; }; f.acceso.addEventListener('change', sync); sync();
if (!HTPASSWD_OK) c.append(el('div', { class: 'err', text: 'No se pueden crear accesos: el servicio no encuentra el archivo de usuarios.' }));
}
return f;
}
const leerMiembro = f => ({ nombre: f.nombre.value.trim(), rol: f.rol.value.trim(), area: f.area.value, correo: f.correo.value.trim(), telefono: f.telefono.value.trim() });
function nuevo(base) {
let f;
abrirDialogo(base ? 'Crear acceso para ' + base.nombre : 'Nuevo usuario / miembro', c => { f = formMiembro(c, base, true); if (base) { f.acceso.checked = true; f.acceso.dispatchEvent(new Event('change')); } },
[['Guardar', async (m, b, pie) => {
const id = Kanban.norm(f.id.value); const d = leerMiembro(f);
if (!id) throw new Error('Escribe el usuario (id).');
if (!d.nombre) throw new Error('Escribe el nombre.');
if (f.acceso.checked) {
if (f.pass.value.length < PASS_MIN) throw new Error('La contraseña inicial debe tener al menos ' + PASS_MIN + ' caracteres.');
const j = await api('/api/usuarios', json('POST', Object.assign({ usuario: id, password_inicial: f.pass.value }, d)));
const pw = f.pass.value;
dlg.querySelector('.dcuerpo').innerHTML = '';
dlg.querySelector('.dcuerpo').append(el('div', { class: 'pass-una-vez', html: '✓ Acceso creado para <b>' + esc(j.usuario) + '</b>.<br>Contraseña inicial (no se vuelve a mostrar): <code>' + esc(pw) + '</code><br><small>Pásasela en persona o por mensaje directo y dile que la cambie en Mi cuenta.</small>' }));
pie.innerHTML = ''; const ok = el('button', { type: 'button', text: 'Listo' }); ok.addEventListener('click', () => dlg.close()); pie.append(ok);
cargar(); return;
}
if (base) throw new Error('Marca «Crear acceso al sitio» para darle acceso.');
await api('/api/equipo', json('POST', Object.assign({ id }, d)));
dlg.close(); mensaje('Miembro ' + esc(d.nombre) + ' creado (sin acceso al sitio).', true); cargar();
}]]);
}
function editar(m) {
let f;
abrirDialogo('Editar ' + m.nombre, c => {
f = formMiembro(c, m, false);
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(m.creado_por) + ' el ' + esc(fmt(m.creado_en)) + (m.tiene_login ? ' · tiene acceso al sitio' : ' · sin acceso al sitio') }));
(m.historial || []).slice().reverse().slice(0, 10).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
c.append(h);
}, [['Guardar', async () => {
const d = leerMiembro(f); const cambios = {};
Object.keys(d).forEach(k => { if (d[k] !== (m[k] || '')) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', cambios));
dlg.close(); mensaje('Guardado.', true); cargar();
}]]);
}
async function desactivar(m) {
if (!confirm('¿Desactivar a ' + m.nombre + '? Deja de aparecer en las listas de responsables; sus pendientes y su historial se quedan.' + (m.tiene_login ? ' Su acceso al sitio NO se quita (usa «Quitar acceso» para eso).' : ''))) return;
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: false })); mensaje(m.nombre + ' desactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
async function reactivar(m) {
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: true })); mensaje(m.nombre + ' reactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
async function quitarAcceso(m) {
if (!confirm('¿Quitar el acceso al sitio a ' + m.nombre + ' (' + m.id + ')? Ya no podrá entrar y quedará como inactivo. Sus actas y pendientes se conservan.')) return;
try { await api('/api/usuario?u=' + encodeURIComponent(m.id), { method: 'DELETE' }); mensaje('Acceso de ' + m.id + ' retirado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
$('bNuevo').addEventListener('click', () => nuevo(null));
cargar();
</script>
</body>
</html>

View File

@ -25,6 +25,10 @@ Las páginas que abres para saber **dónde está el proyecto hoy**: qué tareas
4. Las cantidades del plan de producción (versión → objetivo → fecha) las edita un administrador en el propio tablero; los conteos reales salen de las actas.
5. El Kanban sustituye al *Task Tracker* de Notion, que queda como histórico (sus tareas se importaron una vez).
## [Equipo](equipo.html)
Quién es quién y qué tiene cada uno: miembros, área, si tienen acceso al sitio y sus pendientes abiertos y vencidos. El administrador crea aquí los accesos (ya no por `htpasswd` en el servidor).
## Relacionados
- [Detalle por tarjeta y módulo](../tablero.html) — qué serie pasó cada acta del lote actual.

View File

@ -16,6 +16,7 @@
<a class="activo" href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a href="../hardware/">Tarjetas y hardware</a>
<a href="equipo.html">👥 Equipo</a>
</nav>
</header>

View File

@ -17,6 +17,7 @@
<a href="produccion.html">Producción</a>
<a href="../inventario/inventario.html">Inventario</a>
<a href="../sitios/">Sitios</a>
<a href="equipo.html">👥 Equipo</a>
</nav>
</header>

View File

@ -11,14 +11,43 @@
kb.recargar();
Servidor: GET /api/tareas · POST /api/tareas · PATCH /api/tarea?id= · POST /api/tareas/orden ·
DELETE /api/tarea?id= (solo admin). Cada cambio guarda quién y cuándo en el historial. */
DELETE /api/tarea?id= (solo admin). Cada cambio guarda quién y cuándo en el historial.
Equipo (14-oct): los responsables son miembros de /api/equipo (GET /api/tareas ya trae el mapa
id → nombre). El pendiente exige responsable; se puede escribir texto libre si la persona aún no
está dada de alta (el servidor avisa). Kanban.nombresResp(t, equipo) sirve a otras páginas. */
(function () {
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo' };
const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
const VERSIONES = ['—', 'V5.5', 'V5.6', 'V5.7', 'v6'];
const RESPONSABLES = ['leo', 'michelle', 'aldair', 'jesús', 'alan', 'daniel', 'claude'];
// Lista de respaldo si /api/equipo no responde; lo normal es que el equipo venga del servidor.
const RESPONSABLES = ['leo', 'adriana', 'michelle', 'jesus', 'alan', 'daniel', 'aldair', 'gibran', 'claude'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
// ---- equipo: id → {nombre, activo, area}. 'Jesús' ≡ 'jesus' (sin acentos, minúsculas).
const norm = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9_\-.]/g, '');
let EQUIPO = {};
let equipoProm = null;
function cargarEquipo() {
if (!equipoProm) equipoProm = fetch('/api/equipo?todos=1', { cache: 'no-store' }).then(r => r.json()).then(j => {
const m = {}; (j.miembros || []).forEach(x => m[x.id] = { nombre: x.nombre, activo: x.activo !== false, area: x.area, tiene_login: x.tiene_login });
return m;
}).catch(() => ({})).then(m => { if (!Object.keys(m).length) RESPONSABLES.forEach(id => m[id] = { nombre: id, activo: true }); EQUIPO = Object.assign(EQUIPO, m); return EQUIPO; });
return equipoProm;
}
function usarEquipo(mapa) { if (mapa && Object.keys(mapa).length) EQUIPO = Object.assign(EQUIPO, mapa); if (!Object.keys(EQUIPO).length) RESPONSABLES.forEach(id => EQUIPO[id] = { nombre: id, activo: true }); return EQUIPO; }
const partesResp = t => String((t && t.responsable) || '').split(/[,;\/]+/).map(x => x.trim()).filter(Boolean);
// nombres a mostrar: cada parte del campo responsable → nombre del miembro, o el texto tal cual
function nombresResp(t, eq) {
eq = eq || EQUIPO;
return partesResp(t).map(p => { const a = norm(p.replace(/\s+/g, '')), b = norm(p); return (eq[a] || eq[b] || {}).nombre || p; });
}
function idsResp(t, eq) {
eq = eq || EQUIPO;
if (Array.isArray(t.responsable_ids)) return t.responsable_ids;
return partesResp(t).map(p => { const a = norm(p.replace(/\s+/g, '')), b = norm(p); return eq[a] ? a : (eq[b] ? b : null); }).filter(Boolean);
}
const libresResp = (t, eq) => { eq = eq || EQUIPO; return partesResp(t).filter(p => !eq[norm(p.replace(/\s+/g, ''))] && !eq[norm(p)]); };
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
const hoyISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); };
@ -87,7 +116,11 @@
const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
return tareas.filter(t => {
if (filtro.pista && t.pista !== filtro.pista) return false;
if (filtro.responsable && !String(t.responsable || '').toLowerCase().includes(filtro.responsable)) return false;
if (filtro.responsable) {
const f = filtro.responsable;
if (EQUIPO[f]) { if (!idsResp(t).includes(f)) return false; }
else if (!String(t.responsable || '').toLowerCase().includes(f)) return false;
}
if (filtro.version && t.version_tarjeta !== filtro.version) return false;
if (t.estado === 'hecho') {
if (filtro.hechas === 'ninguna') return false;
@ -107,7 +140,7 @@
const meta = el('div', { class: 'meta' });
if (!opts.pista) meta.append(el('span', { class: 'chip p-' + t.pista, text: PISTAS[t.pista] || t.pista }));
if (t.version_tarjeta && t.version_tarjeta !== '—') meta.append(el('span', { class: BADGE[t.version_tarjeta] || 'chip', text: t.version_tarjeta }));
if (t.responsable) meta.append(el('span', { text: '👤 ' + t.responsable }));
if (t.responsable) meta.append(el('span', { text: '👤 ' + nombresResp(t).join(', '), title: t.responsable }));
if (t.fecha_limite) meta.append(el('span', { class: 'fecha' + (t.vencida ? ' mal' : ''), text: (t.vencida ? '⚠ venció ' : '📅 ') + fechaCorta(t.fecha_limite) }));
(t.etiquetas || []).slice(0, 3).forEach(x => meta.append(el('span', { class: 'chip', text: x })));
c.append(meta);
@ -139,9 +172,10 @@
});
// responsables presentes en los datos (más los conocidos)
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
const set = new Set(RESPONSABLES);
tareas.forEach(t => String(t.responsable || '').split(/[,/]+/).map(s => s.trim().toLowerCase()).filter(Boolean).forEach(s => set.add(s)));
[...set].sort().forEach(r => selResp.append(el('option', { value: r, text: r })));
Object.entries(EQUIPO).filter(([, m]) => m.activo !== false).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre))
.forEach(([id, m]) => selResp.append(el('option', { value: id, text: m.nombre })));
const libres = new Set(); tareas.forEach(t => libresResp(t).forEach(x => libres.add(x.toLowerCase())));
[...libres].sort().forEach(x => selResp.append(el('option', { value: x, text: x + ' (sin registrar)' })));
selResp.value = filtro.responsable;
const venc = vis.filter(t => t.vencida).length;
cnt.textContent = vis.length + ' pendiente(s)' + (venc ? ' · ' + venc + ' vencido(s)' : '') + (vis.length !== tareas.length ? ' · de ' + tareas.length : '');
@ -151,6 +185,7 @@
try {
const j = await api('/api/tareas');
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
usarEquipo(j.equipo);
pintar();
if (opts.onCargado) opts.onCargado(tareas, { admin, user });
} catch (e) { mensaje('No se pudo cargar el tablero: ' + e.message, false); }
@ -192,15 +227,24 @@
const fDesc = el('textarea', { rows: '3', placeholder: 'Detalle, criterio de terminado, notas' }); fDesc.value = t.descripcion || '';
const fPista = el('select'); Object.entries(PISTAS).forEach(([k, v]) => fPista.append(el('option', { value: k, text: v }))); fPista.value = t.pista;
const fEst = el('select'); ESTADOS.forEach(([k, v]) => fEst.append(el('option', { value: k, text: v }))); fEst.value = t.estado;
const fResp = el('input', { type: 'text', value: t.responsable || '', list: 'kb-resp', placeholder: 'leo, michelle, aldair, jesús…' });
let dl = document.getElementById('kb-resp'); if (!dl) { dl = el('datalist', { id: 'kb-resp' }); document.body.append(dl); }
dl.innerHTML = ''; RESPONSABLES.forEach(r => dl.append(el('option', { value: r })));
// responsable: chips de miembros activos (varios) + texto libre opcional. Obligatorio.
const fResp = el('div', { class: 'chips' });
const yaIds = idsResp(t);
Object.entries(EQUIPO).filter(([id, m]) => m.activo !== false || yaIds.includes(id)).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre)).forEach(([id, m]) => {
const l = el('label', { class: 'chip-sel' + (yaIds.includes(id) ? ' on' : '') });
const ck = el('input', { type: 'checkbox', value: id }); ck.checked = yaIds.includes(id);
ck.addEventListener('change', () => l.classList.toggle('on', ck.checked));
l.append(ck, document.createTextNode(m.nombre + (m.activo === false ? ' (inactivo)' : ''))); fResp.append(l);
});
const fLibre = el('input', { type: 'text', value: libresResp(t).join(', '), placeholder: 'Otro (texto libre, solo si la persona aún no está en Equipo)', style: 'margin-top:6px' });
const leerResp = () => [...fResp.querySelectorAll('input:checked')].map(c => c.value).concat(fLibre.value.split(',').map(x => x.trim()).filter(Boolean)).join(', ');
const fFecha = el('input', { type: 'date', value: t.fecha_limite || '' });
const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? '— (no aplica)' : v }))); fVer.value = t.version_tarjeta || '—';
const fEtq = el('input', { type: 'text', value: (t.etiquetas || []).join(', '), placeholder: 'layout, pedido 570, bloqueante' });
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Responsable', fResp), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
const cResp = campo('Responsable (obligatorio; puede ser más de uno)', fResp); cResp.append(fLibre); cuerpo.append(cResp);
// enlaces
const enlBox = el('div', { class: 'enlaces-ed' });
@ -243,19 +287,21 @@
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: nuevo ? 'Crear pendiente' : 'Guardar cambios' });
guardar.addEventListener('click', async () => {
const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: fEst.value, responsable: fResp.value.trim(),
const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: fEst.value, responsable: leerResp(),
fecha_limite: fFecha.value || null, version_tarjeta: fVer.value, etiquetas: fEtq.value.split(',').map(s => s.trim()).filter(Boolean),
enlaces: [...enlBox.querySelectorAll('.fila')].map(f => ({ titulo: f.children[0].value.trim(), url: f.children[1].value.trim() })).filter(e => e.url),
bloqueada_por: [...lst.querySelectorAll('input:checked')].map(c => c.value) };
if (!d.titulo) { m.className = 'msg err'; m.textContent = 'Escribe el título.'; return; }
if (!d.responsable) { m.className = 'msg err'; m.textContent = 'Elige al menos un responsable del equipo (o escribe uno en texto libre).'; fResp.scrollIntoView({ block: 'center' }); return; }
guardar.disabled = true;
try {
if (nuevo) await api('/api/tareas', json('POST', d));
let r;
if (nuevo) r = await api('/api/tareas', json('POST', d));
else {
const cambios = {}; Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
if (Object.keys(cambios).length) r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
}
dlg.close(); mensaje(nuevo ? 'Pendiente creado.' : 'Cambios guardados.', true); recargar();
dlg.close(); mensaje((nuevo ? 'Pendiente creado.' : 'Cambios guardados.') + (r && r.aviso ? ' ⚠ ' + r.aviso : ''), !(r && r.aviso)); recargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
@ -280,5 +326,5 @@
return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; } };
}
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES };
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, get equipo() { return EQUIPO; } };
})();

View File

@ -17,6 +17,7 @@
<a class="activo" href="produccion.html">Producción</a>
<a href="../tablero.html">Detalle por tarjeta</a>
<a href="../inventario/inventario.html">Inventario</a>
<a href="equipo.html">👥 Equipo</a>
<button id="refrescar" style="padding:6px 12px">Actualizar</button>
</nav>
</header>
@ -36,6 +37,7 @@
</div>
<dialog id="dlgPlan"></dialog>
<script src="kanban.js"></script>
<script>
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; };
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
@ -48,7 +50,7 @@ const FORM = { 'prueba-v5': '../formularios/v5.html', 'prueba-v4': '../formulari
'prueba-ethernet-serie': '../formularios/ethernet-serie.html', 'envio': '../formularios/envio.html', 'instalacion-campo': '../formularios/instalacion.html' };
const PISTAS = { produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', ingenieria: 'Ingeniería', campo: 'Campo' };
const cont = document.getElementById('cont'), meta = document.getElementById('meta');
let PLAN = null, ADMIN = false;
let PLAN = null, ADMIN = false, EQUIPO = {};
async function api(url, opts) {
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
@ -143,7 +145,7 @@ function render(plan, actas, series, danos, bloqueos, inv) {
bloqueos.forEach(t => {
const c = el('div', { class: 'card p-' + t.pista });
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span>' + (t.responsable ? '<span>👤 ' + esc(t.responsable) + '</span>' : '<span>sin responsable</span>') + (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">📅 ' + esc(t.fecha_limite) + '</span>' : '') }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span>' + (t.responsable ? '<span title="' + esc(t.responsable) + '">👤 ' + esc(Kanban.nombresResp(t, EQUIPO).join(', ')) + '</span>' : '<span>sin responsable</span>') + (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">📅 ' + esc(t.fecha_limite) + '</span>' : '') }));
if (t.descripcion) c.append(el('div', { style: 'font-size:13px;color:var(--tinta2);margin-top:4px', text: t.descripcion.length > 160 ? t.descripcion.slice(0, 159) + '…' : t.descripcion }));
b3.append(c);
});
@ -220,7 +222,7 @@ async function cargar() {
const [p, a, s, d, t, i] = await Promise.all([
api('/api/plan'), api('/api/actas'), api('/api/series').catch(() => ({ series: [] })), api('/api/danos').catch(() => ({ series: [] })),
api('/api/tareas?estado=bloqueado').catch(() => ({ tareas: [] })), api('/api/inventario?alertas=1').catch(() => ({ items: [] }))]);
PLAN = p.plan; ADMIN = !!p.admin;
PLAN = p.plan; ADMIN = !!p.admin; EQUIPO = Kanban.usarEquipo(t.equipo);
const bloqueos = (t.tareas || []).filter(x => ['produccion', 'mecanica', 'compras'].includes(x.pista));
render(PLAN, a.actas || [], s.series || [], d.series || [], bloqueos, i.items || []);
meta.textContent = 'Actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + ' · ' + (a.total || 0) + ' actas · ' + (s.series || []).length + ' series con registro';

View File

@ -107,6 +107,11 @@ dialog input,dialog select,dialog textarea{width:100%}
.lista-chk{max-height:140px;overflow:auto;border:1px solid var(--linea);border-radius:9px;padding:6px 10px;font-size:13.5px}
.lista-chk label{display:flex;gap:8px;align-items:flex-start;font-weight:500;margin:4px 0;color:var(--tinta)}
.lista-chk input{width:auto;margin-top:3px}
/* responsables: chips seleccionables (miembros del equipo) */
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip-sel{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--linea);border-radius:99px;padding:5px 11px;font-size:13.5px;font-weight:500;cursor:pointer;background:var(--superf);margin:0;color:var(--tinta)}
.chip-sel input{width:auto;margin:0;accent-color:var(--naranja)}
.chip-sel.on{border-color:var(--naranja);background:var(--naranja-suave);color:var(--naranja);font-weight:700}
.hist{font-size:13px;color:var(--tinta2);max-height:180px;overflow:auto;border-top:1px solid var(--linea);margin-top:12px;padding-top:8px}
.hist div{padding:3px 0;border-bottom:1px dashed var(--linea)}
.hist b{color:var(--tinta)}

View File

@ -67,6 +67,7 @@ nav:
- "📋 Tableros":
- tableros/index.md
- "Kanban — tareas del equipo": tableros/kanban.html
- "Equipo — miembros, responsables y accesos": tableros/equipo.html
- "Ingeniería — estado por versión de tarjeta": tableros/ingenieria.html
- "Producción — plan 570 vs. avance real": tableros/produccion.html
- "Estado de actas (tarjetas, módulos, unidades)": tablero.html

View File

@ -16,7 +16,7 @@ echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
# El backend NO vive en git en el servidor: se copia igual que el sitio.
# Sin --delete (ahí junto al app.py viven el venv y __pycache__).
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt backend/seed_1013.py \
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt \
backend/parche-nginx-api.py backend/actas-hbox.service "$REMOTE:$BACKEND_PATH/"
echo "Backend copiado en $REMOTE:$BACKEND_PATH"
echo