Pendientes: comentarios en cada tarjeta y «¿por qué?» al moverla

Pedido de Leo (28-oct, 12:57 y 16:12). El detalle tiene «Comentarios» (hilo + caja; cualquiera con sesión comenta, aunque
no sea el campeón) y la tarjeta dice cuántos. Al mover pregunta «¿por qué?»: a Hecho (la evidencia, D-27-6), al reabrir
desde Hecho, a Bloqueado y de En curso a Por hacer; «Cancelar» o Esc la deja donde estaba. Vale para arrastrar, la franja,
las flechas, «Mover a…» y los botones del detalle. En el teléfono el detalle cabe en la pantalla.
Backend: _nota hasta 1000 caracteres y guardada aparte en historial[-1].nota. Pruebas: tests-comentarios.sh (16) +
1027 (55) + 1028 Compras (58) + 1023 (35) + campeón (25) + tests-actas-documentos.sh (15, antes tests-1028.sh, nombre que
pasó a Compras). Desplegado (DESPLIEGUE A13). Lote 2026-10-28_comentarios-pendientes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
This commit is contained in:
Leopoldo Rodríguez 2026-10-28 16:23:43 -06:00
parent dd160bd70c
commit 62c037f198
9 changed files with 361 additions and 12 deletions

View File

@ -573,12 +573,30 @@ de la galería, y no había dónde subir un documento terminado.
los primeros bytes (un HTML renombrado a .pdf se rechaza). `/api/adjunto` manda `nosniff`, abre en el navegador fotos, los primeros bytes (un HTML renombrado a .pdf se rechaza). `/api/adjunto` manda `nosniff`, abre en el navegador fotos,
PDF y texto, y descarga lo demás con su nombre original. PDF y texto, y descarga lo demás con su nombre original.
- `docs/tableros/unidad.md`: el expediente dice «Archivos y documentos». - `docs/tableros/unidad.md`: el expediente dice «Archivos y documentos».
- Pruebas: `bash backend/tests-1028.sh .` (15) además de 1027 (55), 1023 (35) y campeón (25); en teléfono, claro y oscuro. - Pruebas: `bash backend/tests-actas-documentos.sh .` (15; se llamaba tests-1028.sh, nombre que pasó a Compras) además de 1027 (55), 1023 (35) y campeón (25); en teléfono, claro y oscuro.
Ya está desplegado (28-oct, 12:17): páginas con rsync después de la comparación con el servidor, y el backend con Ya está desplegado (28-oct, 12:17): páginas con rsync después de la comparación con el servidor, y el backend con
`kill -HUP` al proceso maestro de gunicorn (corre como `leo`: recarga los workers sin sudo y sin cortar). Si algo `kill -HUP` al proceso maestro de gunicorn (corre como `leo`: recarga los workers sin sudo y sin cortar). Si algo
falla: `git revert` del commit y `./scripts/deploy.sh`, luego `ssh -t leo@git.helgen.online 'sudo systemctl restart actas-hbox'`. falla: `git revert` del commit y `./scripts/deploy.sh`, luego `ssh -t leo@git.helgen.online 'sudo systemctl restart actas-hbox'`.
## A13 · 28-oct: comentarios en los pendientes y «¿por qué?» al moverlos
Lo pidió Leo (28-oct, 12:57 y 16:12): que cada tarjeta tenga comentarios y que se pueda justificar por qué se movió,
sobre todo cuando algo pasó a Hecho sin estar terminado.
- `backend/app.py` (PATCH `/api/tarea`): `_nota` hasta 1000 caracteres (antes 300) y además del texto del historial queda
aparte en `historial[-1].nota`. Comentar no necesita ser campeón; mover sí (sin cambio).
- `docs/tableros/kanban.js` + `tablero.css`: sección **Comentarios** en el detalle (hilo + caja) y contador en la tarjeta;
`reglaMover()` pide «¿por qué?» en el mismo PATCH: a Hecho (evidencia, D-27-6), al reabrir desde Hecho, a Bloqueado y de
En curso a Por hacer; «Cancelar» o Esc lo deja donde estaba. Lo demás se mueve sin preguntar. Vale para arrastrar, la
franja, las flechas, «Mover a…» y los botones del detalle. En el teléfono el detalle cabe en la pantalla.
- `docs/tableros/pendientes.md` («Cómo se usa») y `juntas.md` (D-27-6) lo explican.
- Pruebas: `bash backend/tests-comentarios.sh .` (16) además de 1027 (55), 1028 Compras (58), 1023 (35), campeón (25) y
actas-documentos (15); en el navegador: escritorio y teléfono (iPhone 13 y SE), claro y oscuro.
Desplegado el 28-oct: páginas con rsync después de la comparación con el servidor, backend con `kill -HUP` al maestro de
gunicorn. Para deshacer: `git revert` del commit, `./scripts/deploy.sh` y `ssh -t leo@git.helgen.online 'sudo systemctl restart actas-hbox'`.
## B · Verificación después de desplegar ## B · Verificación después de desplegar
```bash ```bash

View File

@ -1532,7 +1532,9 @@ def tarea_modificar():
if k == "orden": if k == "orden":
continue # la posición dentro de la columna no ensucia el historial continue # la posición dentro de la columna no ensucia el historial
cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}") cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}")
nota = _texto(d.get("_nota"), 300) # comentario (28-oct): el «¿por qué?» al mover y los comentarios de la tarjeta; hasta 1000 caracteres. Va en el
# texto del historial (la Actividad lo lee así) y aparte en «nota», para el hilo de comentarios del detalle.
nota = _texto(d.get("_nota"), 1000)
if not cambios and not nota: if not cambios and not nota:
if t.get("orden") != base.get("orden") or t.get("responsable_ids") != base.get("responsable_ids"): 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 # solo cambió la posición (o se completaron los ids): se guarda sin entrada en el historial
@ -1544,6 +1546,8 @@ def tarea_modificar():
t["vencida"] = _vencida(t) t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, sin_cambios=True, aviso=aviso) return jsonify(ok=True, tarea=t, sin_cambios=True, aviso=aviso)
_hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota) _hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota)
if nota:
t["historial"][-1]["nota"] = nota
_guardar_tarea(t) _guardar_tarea(t)
t["vencida"] = _vencida(t) t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, aviso=aviso) return jsonify(ok=True, tarea=t, aviso=aviso)

View File

@ -0,0 +1,19 @@
{
"lote": "2026-10-28_comentarios-pendientes",
"autor": "claude",
"motivo": "28-oct (Leo, 12:57 y 16:12): comentarios en cada pendiente y «¿por qué?» al moverlo, sobre todo cuando algo pasó a Hecho sin estar terminado. Hecho y publicado el mismo día.",
"fuente": "/tableros/pendientes/",
"cambios": [
{ "crear": { "id": "t-pendientes-comentarios",
"titulo": "Pendientes: comentarios en cada tarjeta y «¿por qué?» al moverla (a Hecho pide la evidencia)",
"pista": "gestion", "estado": "hecho", "responsable": "claude",
"sitios": ["qro", "icave", "timsa", "eit", "lzc"], "etiquetas": ["quick-win"],
"descripcion": "Pedido de Leo (28-oct). Cada pendiente tiene «Comentarios» en el detalle (cualquiera comenta, aunque no sea el campeón) y la tarjeta dice cuántos tiene. Al moverlo pregunta «¿por qué?»: a Hecho, la evidencia (D-27-6); al reabrir algo hecho, qué faltó; a Bloqueado, qué lo bloquea y quién lo destraba; de En curso a Por hacer, por qué regresa. «Cancelar» lo deja donde estaba. Evidencia: 16 pruebas nuevas del servidor (tests-comentarios.sh) y las anteriores (55 + 58 + 35 + 25 + 15) bien; probado en escritorio y teléfono, claro y oscuro; publicado el 28-oct (DESPLIEGUE A13).",
"enlaces": [{ "titulo": "Pendientes · Cómo se usa", "url": "/tableros/pendientes/" },
{ "titulo": "D-27-6: «hecho» con evidencia", "url": "/tableros/juntas/#proceso" }] },
"si_no_hay": ["comentarios en cada tarjeta"] },
{ "id": "d-27-6-proceso",
"si_estado": ["hecho"],
"nota": "28-oct: el tablero ya pide la evidencia al mover a Hecho y la guarda en los comentarios del pendiente (t-pendientes-comentarios)." }
]
}

View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Pruebas del 28-oct (antes tests-1028.sh; ese nombre pasó a Compras): documentos terminados en las actas (/api/archivos acepta PDF, Word, Excel…, con revisión del
# contenido) y cómo se sirven (/api/adjunto: PDF y fotos en el navegador, lo demás se descarga, nosniff).
# Arranca app.py con carpetas temporales. No toca el servidor.
# Uso: bash backend/tests-actas-documentos.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8096}
T=$(mktemp -d); export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock SESION_COOKIE_INSEGURA=1 \
ACTAS_ADMINS=leo PORT ADJUNTOS_DIR=$T/adjuntos
mkdir -p $ACTAS_DIR $T/equipo $T/tareas $T/f
touch $T/htpasswd
python3 $REPO/backend/app.py > $T/app.log 2>&1 &
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8
U="http://127.0.0.1:$PORT"
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:220}"; fi; }
sube() { curl -s -H "X-Remote-User: jesush" -F tipo=instalacion-campo -F serie=HBOX5-E15 -F etiqueta=Documento -F "archivo=@$1" $U/api/archivos; }
# archivos de prueba
python3 - "$T/f" <<'EOF'
import sys, os, zipfile, io
d = sys.argv[1]
open(os.path.join(d, "hoja-firmada.pdf"), "wb").write(b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n")
z = io.BytesIO(); zipfile.ZipFile(z, "w").writestr("word/document.xml", "<w:document/>")
open(os.path.join(d, "reporte.docx"), "wb").write(z.getvalue())
open(os.path.join(d, "conteo.xlsx"), "wb").write(z.getvalue())
open(os.path.join(d, "viejo.xls"), "wb").write(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0" * 64)
open(os.path.join(d, "falso.pdf"), "wb").write(b"<html><script>alert(1)</script></html>")
open(os.path.join(d, "pagina.html"), "wb").write(b"<html></html>")
open(os.path.join(d, "programa.exe"), "wb").write(b"MZ\x90\x00")
open(os.path.join(d, "captura.log"), "wb").write(b"$GNGGA,1,2,3\n")
EOF
echo "## documentos en /api/archivos"
R=$(sube $T/f/hoja-firmada.pdf); check "PDF se acepta" '"ok":true' "$R"
PDF=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['archivos'][0]['ruta'])")
R=$(sube $T/f/reporte.docx); check "Word (.docx) se acepta" '"ok":true' "$R"
DOCX=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['archivos'][0]['ruta'])")
check "Excel (.xlsx) se acepta" '"ok":true' "$(sube $T/f/conteo.xlsx)"
check "Excel viejo (.xls) se acepta" '"ok":true' "$(sube $T/f/viejo.xls)"
check "PDF falso (HTML con .pdf) se rechaza" 'no es un PDF' "$(sube $T/f/falso.pdf)"
check ".html se rechaza" 'n no permitida' "$(sube $T/f/pagina.html)"
check ".exe se rechaza" 'n no permitida' "$(sube $T/f/programa.exe)"
check "los logs siguen igual" '"ok":true' "$(sube $T/f/captura.log)"
echo "## cómo se sirven (/api/adjunto)"
H=$(curl -s -D - -o /dev/null -H "X-Remote-User: leo" "$U/api/adjunto?ruta=$PDF")
check "PDF abre en el navegador (inline)" 'inline' "$H"
check "PDF con su nombre original" 'hoja-firmada.pdf' "$H"
check "PDF con nosniff" 'nosniff' "$H"
check "PDF como application/pdf" 'application/pdf' "$H"
H=$(curl -s -D - -o /dev/null -H "X-Remote-User: leo" "$U/api/adjunto?ruta=$DOCX")
check "Word se descarga (attachment)" 'attachment' "$H"
check "Word con su nombre original" 'reporte.docx' "$H"
echo "## el expediente los lista"
E=$(curl -s -H "X-Remote-User: leo" "$U/api/unidad?serie=HBOX5-E15")
check "expediente cuenta 5 archivos (4 documentos + 1 log)" '"total_logs":5' "$E"
echo
echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
[ $fail -eq 0 ]

53
backend/tests-comentarios.sh Executable file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Pruebas del 28-oct (Leo): comentarios en los pendientes y el «¿por qué?» al moverlos. PATCH /api/tarea con «_nota»:
# solo el comentario (cualquiera con sesión, aunque no sea el campeón) o junto con el cambio de columna (solo el campeón).
# La nota queda en el historial en «cambio» (como siempre, para la Actividad) y aparte en «nota» (el hilo del detalle).
# Arranca app.py con datos temporales; no toca el servidor.
# Uso: bash backend/tests-comentarios.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8095}
T=$(mktemp -d); export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock ACTAS_ADMINS=leo PORT
mkdir -p $ACTAS_DIR $T/equipo $T/tareas; : > $T/htpasswd
for u in leo adriana jesus michelle claude; do
echo "{\"id\":\"$u\",\"nombre\":\"$u\",\"area\":\"ingenieria\",\"activo\":true}" > $T/equipo/$u.json
done
echo '{"id":"t-a","titulo":"Cotizar gabinetes","responsable":"adriana, jesus","campeon":"adriana","estado":"en-curso","pista":"compras","sitios":["qro"],"orden":0,"historial":[]}' > $T/tareas/t-a.json
python3 $REPO/backend/app.py > $T/app.log 2>&1 &
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8
U="http://127.0.0.1:$PORT"
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:220}"; fi; }
as() { curl -s -H "X-Remote-User: $1" -H "Content-Type: application/json" "${@:2}"; }
ultimo() { python3 -c "import json,sys; d=json.load(sys.stdin); h=d['tarea']['historial'][-1]; print(json.dumps(h, ensure_ascii=False))"; }
echo "## comentar"
R=$(as michelle -X PATCH -d '{"_nota":"Ya pedí la cotización a dos proveedores."}' "$U/api/tarea?id=t-a")
check "quien no es campeón comenta" '"ok":true' "$R"
check "el comentario queda en «nota»" '"nota": "Ya pedí la cotización a dos proveedores."' "$(echo "$R" | ultimo)"
check "y en «cambio» (para la Actividad)" '"cambio": "Ya pedí la cotización a dos proveedores."' "$(echo "$R" | ultimo)"
check "con su autor" '"quien": "michelle"' "$(echo "$R" | ultimo)"
check "comentar no mueve la columna" '"estado":"en-curso"' "$R"
LARGO=$(python3 -c "print('x'*1200)")
R=$(as michelle -X PATCH -d "{\"_nota\":\"$LARGO\"}" "$U/api/tarea?id=t-a")
check "un comentario largo se recorta a 1000" "1000" "$(echo "$R" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['tarea']['historial'][-1]['nota']))")"
check "sin sesión no se comenta" '"ok":false' "$(curl -s -H 'Content-Type: application/json' -X PATCH -d '{"_nota":"hola"}' "$U/api/tarea?id=t-a")"
check "un comentario vacío no deja rastro" '"sin_cambios":true' "$(as michelle -X PATCH -d '{"_nota":" "}' "$U/api/tarea?id=t-a")"
echo "## mover con su porqué"
check "quien no es campeón no mueve, aunque traiga porqué" 'campe' "$(as michelle -X PATCH -d '{"estado":"hecho","_nota":"listo"}' "$U/api/tarea?id=t-a")"
R=$(as adriana -X PATCH -d '{"estado":"hecho","_nota":"Evidencia: cotización COT-123 en el cotizador."}' "$U/api/tarea?id=t-a")
check "el campeón mueve a Hecho con la evidencia" '"estado":"hecho"' "$R"
check "el historial dice el cambio y la nota" 'estado: en-curso → hecho — Evidencia: cotización COT-123' "$(echo "$R" | ultimo)"
check "y la nota sola en «nota»" '"nota": "Evidencia: cotización COT-123 en el cotizador."' "$(echo "$R" | ultimo)"
R=$(as adriana -X PATCH -d '{"estado":"en-curso","_nota":"Faltó la firma del proveedor."}' "$U/api/tarea?id=t-a")
check "reabrir con lo que faltó" 'estado: hecho → en-curso — Faltó la firma del proveedor.' "$(echo "$R" | ultimo)"
R=$(as adriana -X PATCH -d '{"estado":"bloqueado"}' "$U/api/tarea?id=t-a")
check "mover sin nota sigue funcionando (lotes, standup)" '"estado":"bloqueado"' "$R"
check "y sin nota no hay «nota»" 'None' "$(echo "$R" | python3 -c "import json,sys; print(json.load(sys.stdin)['tarea']['historial'][-1].get('nota'))")"
echo "## la Actividad sigue leyendo el historial"
check "la Actividad muestra el comentario" 'a dos proveedores' "$(as leo "$U/api/actividad?dias=2")"
echo
echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
[ $fail -eq 0 ]

View File

@ -71,7 +71,8 @@ El responsable principal no es jefe de los demás: es quien responde por el siti
en el [tablero](pendientes.md) con responsable, fecha y sitio, o los propone en la junta semanal para que entren en un lote. en el [tablero](pendientes.md) con responsable, fecha y sitio, o los propone en la junta semanal para que entren en un lote.
2. **Standup escrito todos los días, contestado antes de las 10:00**, en [Standup](standup.md) (el bot lo prepara la tarde 2. **Standup escrito todos los días, contestado antes de las 10:00**, en [Standup](standup.md) (el bot lo prepara la tarde
anterior), y después una **llamada de 10 min**. anterior), y después una **llamada de 10 min**.
3. **«Hecho» pide evidencia:** el acta, la liga o el archivo que lo demuestra, en el pendiente. Si falta, el bot la pide 3. **«Hecho» pide evidencia:** el acta, la liga o el archivo que lo demuestra, en el pendiente. Desde el 28-oct el tablero
la pregunta al mover a Hecho y la guarda en los comentarios. Si aun así falta (por ejemplo, se marcó en el standup), el bot la pide
en el standup del día siguiente. en el standup del día siguiente.
## Cadencia ## Cadencia

View File

@ -32,7 +32,11 @@
responsables; sin él, el primero). Su avatar va primero, con anillo. Solo el campeón, los administradores y responsables; sin él, el primero). Su avatar va primero, con anillo. Solo el campeón, los administradores y
«mueven_siempre» de GET /api/tareas lo cambian de columna; el servidor lo vuelve a revisar. Para mover: arrastrar «mueven_siempre» de GET /api/tareas lo cambian de columna; el servidor lo vuelve a revisar. Para mover: arrastrar
(al empezar aparece arriba una franja fija con las cuatro columnas, para no tener que bajar con el mouse), (al empezar aparece arriba una franja fija con las cuatro columnas, para no tener que bajar con el mouse),
las flechas ← → de la tarjeta o los botones de columna del detalle. */ las flechas ← → de la tarjeta o los botones de columna del detalle.
Comentarios (28-oct, Leo): el detalle tiene «Comentarios» (hilo + caja; PATCH solo con «_nota», cualquiera con
sesión) y la tarjeta muestra cuántos tiene. Al mover se pide «¿por qué?» cuando hace falta (reglaMover): a Hecho
(la evidencia, D-27-6), al reabrir algo hecho, a Bloqueado y de En curso a Por hacer. «Cancelar» la deja donde estaba.
La nota va en «_nota» del mismo PATCH y queda en el historial («nota»). */
(function () { (function () {
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo', gestion: 'Gestión' }; const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo', gestion: 'Gestión' };
const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']]; const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
@ -76,7 +80,8 @@
actualizar: 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4', actualizar: 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4',
imprimir: 'M7 8.5V4h10v4.5M7 17H5a1.5 1.5 0 0 1-1.5-1.5v-5A1.5 1.5 0 0 1 5 9h14a1.5 1.5 0 0 1 1.5 1.5v5A1.5 1.5 0 0 1 19 17h-2M7 14h10v6H7z', imprimir: 'M7 8.5V4h10v4.5M7 17H5a1.5 1.5 0 0 1-1.5-1.5v-5A1.5 1.5 0 0 1 5 9h14a1.5 1.5 0 0 1 1.5 1.5v5A1.5 1.5 0 0 1 19 17h-2M7 14h10v6H7z',
columnas: 'M5 4.5h14a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18V6A1.5 1.5 0 0 1 5 4.5zM9.3 4.5v15M14.7 4.5v15', columnas: 'M5 4.5h14a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18V6A1.5 1.5 0 0 1 5 4.5zM9.3 4.5v15M14.7 4.5v15',
sitio: 'M12 20.5s-6.5-5.4-6.5-10.3a6.5 6.5 0 0 1 13 0c0 4.9-6.5 10.3-6.5 10.3zM12 7.7a2.4 2.4 0 1 0 0 4.8 2.4 2.4 0 0 0 0-4.8' sitio: 'M12 20.5s-6.5-5.4-6.5-10.3a6.5 6.5 0 0 1 13 0c0 4.9-6.5 10.3-6.5 10.3zM12 7.7a2.4 2.4 0 1 0 0 4.8 2.4 2.4 0 0 0 0-4.8',
comentario: 'M5 5.5h14A1.5 1.5 0 0 1 20.5 7v8.5A1.5 1.5 0 0 1 19 17h-8.5L6 20.5V17H5a1.5 1.5 0 0 1-1.5-1.5V7A1.5 1.5 0 0 1 5 5.5zM8 10h8M8 13h5'
}; };
const icono = (n, cls) => '<svg class="i' + (cls ? ' ' + cls : '') + '" viewBox="0 0 24 24" aria-hidden="true"><path d="' + (ICONOS[n] || '') + '"/></svg>'; const icono = (n, cls) => '<svg class="i' + (cls ? ' ' + cls : '') + '" viewBox="0 0 24 24" aria-hidden="true"><path d="' + (ICONOS[n] || '') + '"/></svg>';
// Íconos de estado de las columnas (con relleno; colores de los tokens). // Íconos de estado de las columnas (con relleno; colores de los tokens).
@ -175,6 +180,41 @@
} }
const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const json = (method, body) => ({ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
// ---- comentarios (28-oct): salen del historial. Desde hoy el servidor guarda la nota aparte («nota»); en lo de
// antes, una entrada sin «campo: antes → después» es un comentario (las notas de los lotes, el cotizador…) y en
// «cambios — nota» la nota va después del último «→».
const NOMBRE_ESTADO = Object.fromEntries(ESTADOS);
const NO_ES_COMENTARIO = /^(creado en |borró el pendiente|restaur)/i;
function comentarios(t) {
const out = [];
(t.historial || []).forEach(x => {
const c = String(x.cambio || '');
const mv = c.match(/estado: ([a-z-]+) → ([a-z-]+)/);
let texto = x.nota || '';
if (!texto) {
if (!c || NO_ES_COMENTARIO.test(c)) return;
const flecha = c.lastIndexOf('→');
if (flecha < 0) texto = c;
else { const k = c.indexOf(' — ', flecha); if (k < 0) return; texto = c.slice(k + 3); }
}
if (texto.trim()) out.push({ quien: x.quien, cuando: x.cuando, texto: texto.trim(), de: mv && mv[1], a: mv && mv[2] });
});
return out;
}
// cuándo pide el «¿por qué?» un cambio de columna (null = se mueve sin preguntar)
function reglaMover(desde, hacia) {
if (desde === hacia) return null;
if (hacia === 'hecho') return { titulo: 'Pasar a Hecho', pregunta: '¿Cuál es la evidencia de que quedó hecho?',
ph: 'La liga, el acta o el archivo que lo demuestra (D-27-6). Ejemplo: acta de prueba en sitio de HBOX5-E15, o /formularios/…' };
if (desde === 'hecho') return { titulo: 'Reabrir', pregunta: '¿Qué faltó? ¿Por qué se reabre?',
ph: 'Qué no quedó terminado y qué falta para cerrarlo' };
if (hacia === 'bloqueado') return { titulo: 'Pasar a Bloqueado', pregunta: '¿Qué lo bloquea y quién lo destraba?',
ph: 'Ejemplo: espera el pago a JLC; lo destraba Adriana' };
if (desde === 'en-curso' && hacia === 'por-hacer') return { titulo: 'Regresar a Por hacer', pregunta: '¿Por qué regresa a Por hacer?',
ph: 'Qué cambió para que deje de estar en curso' };
return null;
}
function montar(root, opts) { function montar(root, opts) {
opts = opts || {}; opts = opts || {};
let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = [], muevenSiempre = null; let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = [], muevenSiempre = null;
@ -370,6 +410,13 @@
const pie = el('div', { class: 'pie' }); const pie = el('div', { class: 'pie' });
pie.append(t.responsable ? avatares(t) : el('span', { class: 'vacio', text: 'Sin responsable' })); pie.append(t.responsable ? avatares(t) : el('span', { class: 'vacio', text: 'Sin responsable' }));
const der = el('span', { class: 'der' }); const der = el('span', { class: 'der' });
const nCom = opts.compacto ? 0 : comentarios(t).length;
if (nCom) {
const cm = el('button', { type: 'button', class: 'cm-n', title: nCom + (nCom === 1 ? ' comentario' : ' comentarios'),
'aria-label': nCom + (nCom === 1 ? ' comentario: abrir' : ' comentarios: abrir'), html: icono('comentario') + '<span>' + nCom + '</span>' });
cm.addEventListener('click', ev => { ev.stopPropagation(); abrir(t, null, true); });
der.append(cm);
}
const f = fechaDe(t); const f = fechaDe(t);
if (f) der.append(el('span', { class: 'fecha ' + f.cls, text: f.texto, title: 'Fecha límite ' + t.fecha_limite })); if (f) der.append(el('span', { class: 'fecha ' + f.cls, text: f.texto, title: 'Fecha límite ' + t.fecha_limite }));
// flechas ← → (27-oct): una columna a la izquierda o a la derecha, de un clic (solo el campeón o un administrador) // flechas ← → (27-oct): una columna a la izquierda o a la derecha, de un clic (solo el campeón o un administrador)
@ -576,6 +623,13 @@
return; return;
} }
const mismaCol = t.estado === estado; const mismaCol = t.estado === estado;
// 28-oct: algunos cambios de columna piden «¿por qué?» antes de moverse; «Cancelar» la deja donde estaba
let nota = '';
const regla = mismaCol ? null : reglaMover(t.estado, estado);
if (regla) {
nota = await porQue(t, estado, regla);
if (nota === null) { pintar(); return; }
}
const columna = tareas.filter(x => x.estado === estado && x.id !== id).sort((a, b) => a.orden - b.orden); const columna = tareas.filter(x => x.estado === estado && x.id !== id).sort((a, b) => a.orden - b.orden);
columna.splice(Math.max(0, Math.min(idx, columna.length)), 0, t); columna.splice(Math.max(0, Math.min(idx, columna.length)), 0, t);
const ids = columna.map(x => x.id); const ids = columna.map(x => x.id);
@ -583,18 +637,107 @@
const prev = { estado: t.estado, orden: t.orden }; const prev = { estado: t.estado, orden: t.orden };
t.estado = estado; columna.forEach((x, i) => x.orden = i); pintar(); t.estado = estado; columna.forEach((x, i) => x.orden = i); pintar();
try { try {
if (!mismaCol) await api('/api/tarea?id=' + encodeURIComponent(id), json('PATCH', { estado, orden: ids.indexOf(id) })); if (!mismaCol) await api('/api/tarea?id=' + encodeURIComponent(id), json('PATCH', Object.assign({ estado, orden: ids.indexOf(id) }, nota ? { _nota: nota } : {})));
await api('/api/tareas/orden', json('POST', { estado, ids })); await api('/api/tareas/orden', json('POST', { estado, ids }));
if (!mismaCol) mensaje('«' + corto(t.titulo, 41) + '» pasó a ' + ESTADOS.find(e => e[0] === estado)[1] + '.', true); if (!mismaCol) mensaje('«' + corto(t.titulo, 41) + '» pasó a ' + ESTADOS.find(e => e[0] === estado)[1] + '.' + (nota ? ' El porqué quedó en sus comentarios.' : ''), true);
await recargar(); await recargar();
} catch (e) { t.estado = prev.estado; t.orden = prev.orden; pintar(); mensaje('No se pudo mover: ' + e.message, false); } } catch (e) { t.estado = prev.estado; t.orden = prev.orden; pintar(); mensaje('No se pudo mover: ' + e.message, false); }
} }
// ---------- «¿por qué?» al mover (28-oct) ----------
const dlgPq = el('dialog', { class: 'kb-dlg kb-porque', 'aria-label': 'Por qué se mueve' });
contenedor.append(dlgPq);
function porQue(t, estado, regla) {
return new Promise(listo => {
dlgPq.innerHTML = '';
let valor = null;
const cab = el('div', { class: 'dcab' });
cab.append(el('h2', { text: regla.titulo }));
const x = el('button', { type: 'button', title: 'Cancelar', 'aria-label': 'Cancelar', html: icono('cerrar') });
x.addEventListener('click', () => dlgPq.close());
cab.append(x); dlgPq.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' });
cuerpo.append(el('p', { class: 'pq-que', html: '«' + esc(corto(t.titulo, 90)) + '»<br><span>' + esc(NOMBRE_ESTADO[t.estado] || t.estado) + ' → <b>' + esc(NOMBRE_ESTADO[estado] || estado) + '</b></span>' }));
const lab = el('label', { text: regla.pregunta });
const txt = el('textarea', { rows: '3', maxlength: '1000', placeholder: regla.ph, 'aria-required': 'true' });
lab.setAttribute('for', 'kb-pq-' + t.id); txt.id = 'kb-pq-' + t.id;
const ayuda = el('div', { class: 'd-nota', text: 'Queda en los comentarios del pendiente, con tu nombre.' });
cuerpo.append(lab, txt, ayuda); dlgPq.append(cuerpo);
const pie = el('div', { class: 'dpie' });
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' });
const ok = el('button', { type: 'button', text: 'Mover a ' + (NOMBRE_ESTADO[estado] || estado) });
const listoPara = () => { ok.disabled = txt.value.trim().length < 5; };
txt.addEventListener('input', listoPara); listoPara();
txt.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !ok.disabled) ok.click(); });
cancelar.addEventListener('click', () => dlgPq.close());
ok.addEventListener('click', () => { valor = txt.value.trim(); dlgPq.close(); });
pie.append(cancelar, ok); dlgPq.append(pie);
dlgPq.addEventListener('close', function fin() { dlgPq.removeEventListener('close', fin); listo(valor); });
if (typeof dlgPq.showModal === 'function') dlgPq.showModal(); else dlgPq.setAttribute('open', '');
setTimeout(() => txt.focus(), 50);
});
}
// ---------- comentarios en el detalle (28-oct) ----------
function seccionComentarios(t) {
const caja = el('div', { class: 'd-coment' });
caja.append(el('label', { html: icono('comentario') + '<span>Comentarios</span>' }));
const lista = el('div', { class: 'cm-lista' });
let todos = comentarios(t), verTodo = false;
const N = 4;
function item(c) {
const d = el('div', { class: 'cm' });
const id = norm(c.quien);
d.append(EQUIPO[id] ? avatar(id) : avatar(null, c.quien || '?'));
const cuerpo = el('div', { class: 'cm-c' });
const cab = el('div', { class: 'cm-cab' });
cab.append(el('b', { text: EQUIPO[id] ? nombreCorto(id) : (c.quien || '') }), el('span', { class: 'cm-f', text: fmt(c.cuando) }));
if (c.a) cab.append(el('span', { class: 'cm-mv e-' + c.a, text: '→ ' + (NOMBRE_ESTADO[c.a] || c.a) }));
cuerpo.append(cab, el('div', { class: 'cm-t', text: c.texto }));
d.append(cuerpo);
return d;
}
function pinta() {
lista.innerHTML = '';
if (!todos.length) { lista.append(el('div', { class: 'vacio', text: 'Sin comentarios todavía.' })); return; }
if (todos.length > N && !verTodo) {
const b = el('button', { type: 'button', class: 'cm-mas', text: 'Ver ' + (todos.length - N) + (todos.length - N === 1 ? ' anterior' : ' anteriores') });
b.addEventListener('click', () => { verTodo = true; pinta(); });
lista.append(b);
}
(verTodo ? todos : todos.slice(-N)).forEach(c => lista.append(item(c)));
}
pinta();
caja.append(lista);
const nuevoC = el('div', { class: 'cm-nuevo' });
const txt = el('textarea', { rows: '2', maxlength: '1000', placeholder: user ? 'Escribe un comentario: avance, por qué se movió, qué falta…' : 'Entra con tu usuario para comentar',
'aria-label': 'Nuevo comentario' });
const btn = el('button', { type: 'button', text: 'Comentar' });
const msg = el('div', { class: 'msg' });
const listoPara = () => { btn.disabled = !user || !txt.value.trim(); };
txt.disabled = !user; txt.addEventListener('input', listoPara); listoPara();
btn.addEventListener('click', async () => {
const nota = txt.value.trim(); if (!nota) return;
btn.disabled = true;
try {
const r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', { _nota: nota }));
if (r.tarea) { t.historial = r.tarea.historial; t.modificado_por = r.tarea.modificado_por; t.modificado_en = r.tarea.modificado_en; }
todos = comentarios(t); verTodo = false; pinta();
txt.value = ''; msg.className = 'msg'; pintar();
lista.scrollTop = lista.scrollHeight;
} catch (e) { msg.className = 'msg err'; msg.textContent = 'No se guardó el comentario: ' + e.message; }
listoPara();
});
nuevoC.append(txt, btn);
caja.append(nuevoC, msg);
return caja;
}
// ---------- diálogo: alta y detalle ---------- // ---------- diálogo: alta y detalle ----------
const dlg = el('dialog', { class: 'kb-dlg', 'aria-label': 'Pendiente' }); const dlg = el('dialog', { class: 'kb-dlg', 'aria-label': 'Pendiente' });
contenedor.append(dlg); contenedor.append(dlg);
function campo(lbl, input) { const d = el('div'); d.append(el('label', { text: lbl }), input); return d; } function campo(lbl, input) { const d = el('div'); d.append(el('label', { text: lbl }), input); return d; }
function abrir(t, estadoInicial) { function abrir(t, estadoInicial, aComentarios) {
const nuevo = !t; const nuevo = !t;
t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: estadoInicial || 'por-hacer', responsable: '', fecha_limite: null, enlaces: [], bloqueada_por: [], etiquetas: [], version_tarjeta: '—', t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: estadoInicial || 'por-hacer', responsable: '', fecha_limite: null, enlaces: [], bloqueada_por: [], etiquetas: [], version_tarjeta: '—',
sitios: filtro.sitios.size ? [...filtro.sitios] : ['qro'] }; sitios: filtro.sitios.size ? [...filtro.sitios] : ['qro'] };
@ -661,6 +804,8 @@
: 'Solo ' + quienMueve(t) + ', campeón de este pendiente, o un administrador lo cambia de columna o de campeón.' })); : 'Solo ' + quienMueve(t) + ', campeón de este pendiente, o un administrador lo cambia de columna o de campeón.' }));
cuerpo.append(cMv); cuerpo.append(cMv);
} }
const secCom = nuevo ? null : seccionComentarios(t);
if (secCom) cuerpo.append(secCom);
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc)); cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista)); const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista));
if (nuevo) g1.append(campo('Estado', fEst)); if (nuevo) g1.append(campo('Estado', fEst));
@ -745,7 +890,11 @@
}); });
pie.append(cancelar, guardar); pie.append(cancelar, guardar);
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', ''); if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
setTimeout(() => fTit.focus(), 50); setTimeout(() => {
// solo se desplaza el cuerpo del detalle (scrollIntoView movía también la ventana y escondía el encabezado en el teléfono)
if (aComentarios && secCom) { cuerpo.scrollTop = Math.max(0, secCom.offsetTop - cuerpo.offsetTop - 8); const x = secCom.querySelector('textarea'); if (x && !x.disabled) x.focus({ preventScroll: true }); }
else fTit.focus();
}, 50);
} }
// ---------- eventos de filtros ---------- // ---------- eventos de filtros ----------

View File

@ -18,10 +18,11 @@ hide:
<li>Cada tarjeta del tablero es un <b>pendiente</b> (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.</li> <li>Cada tarjeta del tablero es un <b>pendiente</b> (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.</li>
<li>Para cambiarlo de columna: arrástralo (arriba aparece una franja con las cuatro columnas, suéltalo ahí y no tienes que bajar), usa las flechas <b>← →</b> de la tarjeta o los botones de <b>Columna</b> en el detalle; en el teléfono, <b>Mover a…</b>. El tablero anota quién lo movió y cuándo.</li> <li>Para cambiarlo de columna: arrástralo (arriba aparece una franja con las cuatro columnas, suéltalo ahí y no tienes que bajar), usa las flechas <b>← →</b> de la tarjeta o los botones de <b>Columna</b> en el detalle; en el teléfono, <b>Mover a…</b>. El tablero anota quién lo movió y cuándo.</li>
<li><b>Campeón:</b> aunque un pendiente tenga varios responsables, uno solo lo mueve entre columnas y responde por él (su avatar va primero, con anillo). Se elige en el detalle; sin elegir, es el primero de la lista. Los demás responsables editan todo lo demás; para moverlo, pídele al campeón que te lo pase. Leo (administrador) puede mover cualquiera.</li> <li><b>Campeón:</b> aunque un pendiente tenga varios responsables, uno solo lo mueve entre columnas y responde por él (su avatar va primero, con anillo). Se elige en el detalle; sin elegir, es el primero de la lista. Los demás responsables editan todo lo demás; para moverlo, pídele al campeón que te lo pase. Leo (administrador) puede mover cualquiera.</li>
<li>Toca el título para ver el detalle: descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda.</li> <li><b>¿Por qué?</b> (28-oct): algunos cambios de columna preguntan antes de moverse y lo que escribas queda en los comentarios con tu nombre. A <b>Hecho</b>: la evidencia (el acta, la liga o el archivo). Al <b>reabrir</b> algo que estaba en Hecho: qué faltó. A <b>Bloqueado</b>: qué lo bloquea y quién lo destraba. De En curso a Por hacer: por qué regresa. «Cancelar» lo deja donde estaba.</li>
<li>Toca el título para ver el detalle: <b>comentarios</b>, descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda. Cualquiera puede comentar, aunque no sea el campeón; el número junto a la fecha de la tarjeta dice cuántos comentarios tiene (tócalo para abrirlos).</li>
<li><b>Nuevo pendiente</b>: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».</li> <li><b>Nuevo pendiente</b>: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».</li>
<li>Columna <b>Bloqueado</b>: solo si no puedes avanzar. Escribe en la descripción qué falta y quién lo destraba.</li> <li>Columna <b>Bloqueado</b>: solo si no puedes avanzar; al moverlo te pregunta qué falta y quién lo destraba.</li>
<li>Al terminar, muévelo a <b>Hecho</b>. No lo borres: solo el administrador borra, y el historial sirve de bitácora.</li> <li>Al terminar, muévelo a <b>Hecho</b> con su evidencia. No lo borres: solo el administrador borra, y el historial sirve de bitácora.</li>
<li>Rojo = vencido. Filtra por pista, responsable o versión de tarjeta; los filtros no cambian nada en el servidor.</li> <li>Rojo = vencido. Filtra por pista, responsable o versión de tarjeta; los filtros no cambian nada en el servidor.</li>
<li><b>Sitio</b>: cada pendiente dice dónde pasa (ICAVE, TIMSA, EIT, LCT o Querétaro). Los botones de sitio dejan solo los de tu sitio (puedes elegir varios); <b>Por sitio</b> muestra una fila por sitio. Al crear un pendiente con un sitio filtrado, ese sitio ya viene marcado. La dirección (<code>?sitio=eit</code>) se puede compartir.</li> <li><b>Sitio</b>: cada pendiente dice dónde pasa (ICAVE, TIMSA, EIT, LCT o Querétaro). Los botones de sitio dejan solo los de tu sitio (puedes elegir varios); <b>Por sitio</b> muestra una fila por sitio. Al crear un pendiente con un sitio filtrado, ese sitio ya viene marcado. La dirección (<code>?sitio=eit</code>) se puede compartir.</li>
<li><b>Por persona</b> muestra una fila por responsable con sus pendientes en cada estado: sirve para ver quién está cargado y quién está libre. La dirección (<code>?vista=persona</code>, <code>?mios=1</code>) se puede compartir tal cual.</li> <li><b>Por persona</b> muestra una fila por responsable con sus pendientes en cada estado: sirve para ver quién está cargado y quién está libre. La dirección (<code>?vista=persona</code>, <code>?mios=1</code>) se puede compartir tal cual.</li>

View File

@ -735,3 +735,45 @@
.tb .kb-s-pin .i { width: 16px; height: 16px; } .tb .kb-s-pin .i { width: 16px; height: 16px; }
.tb .kb-p-quien .kb-s-pin { width: 30px; height: 30px; align-items: center; justify-content: center; border-radius: 50%; background: var(--hg-plano-2); flex: none; } .tb .kb-p-quien .kb-s-pin { width: 30px; height: 30px; align-items: center; justify-content: center; border-radius: 50%; background: var(--hg-plano-2); flex: none; }
.tb .chip-sel .kb-s-pin .i { width: 13px; height: 13px; } .tb .chip-sel .kb-s-pin .i { width: 13px; height: 13px; }
/* ---- Comentarios y «¿por qué?» al mover (28-oct) ------------------------- */
.tb .card .cm-n {
display: inline-flex; align-items: center; gap: 3px; height: 24px; padding: 0 6px;
border-radius: 6px; border: 1px solid transparent; background: transparent; color: var(--hg-texto-2);
font-size: 12.5px; font-weight: 600;
}
.tb .card .cm-n svg { width: 15px; height: 15px; }
.tb .card .cm-n:hover:not(:disabled) { border-color: var(--hg-linea-2); color: var(--hg-texto-fuerte); filter: none; }
.tb .d-coment { margin-top: 2px; }
.tb .d-coment > label { display: flex; align-items: center; gap: 6px; }
.tb .d-coment > label .i { width: 15px; height: 15px; }
.tb .cm-lista { max-height: 260px; overflow: auto; border: 1px solid var(--hg-linea); border-radius: 10px; background: var(--hg-plano); padding: 4px 10px; }
.tb .cm-lista > .vacio { display: block; padding: 8px 0; font-size: 13.5px; }
.tb .cm { display: flex; gap: 9px; align-items: flex-start; padding: 8px 0; border-bottom: 1px dashed var(--hg-linea); }
.tb .cm:last-child { border-bottom: 0; }
.tb .cm .av { margin-top: 1px; }
.tb .cm-c { min-width: 0; flex: 1; }
.tb .cm-cab { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; font-size: 13px; }
.tb .cm-cab b { color: var(--hg-texto-fuerte); font-weight: 600; }
.tb .cm-f { color: var(--hg-texto-3); font-size: 12px; }
.tb .cm-mv { font-size: 12px; font-weight: 600; padding: 0 6px; border-radius: 5px; background: var(--hg-pend-bg); color: var(--hg-texto-2); }
.tb .cm-mv.e-en-curso { background: var(--hg-curso-bg); color: var(--hg-curso); }
.tb .cm-mv.e-bloqueado { background: var(--hg-bloqueado-bg); color: var(--hg-bloqueado); }
.tb .cm-mv.e-hecho { background: var(--hg-hecho-bg); color: var(--hg-hecho); }
.tb .cm-t { margin-top: 2px; font-size: 14px; line-height: 1.45; color: var(--hg-texto); white-space: pre-wrap; overflow-wrap: anywhere; }
.tb button.cm-mas { background: none; border: 0; padding: 6px 0 2px; font-size: 13px; font-weight: 500; color: var(--hg-liga); }
.tb button.cm-mas:hover:not(:disabled) { filter: none; text-decoration: underline; }
.tb .cm-nuevo { display: flex; gap: 8px; align-items: flex-end; margin-top: 8px; }
.tb .cm-nuevo textarea { flex: 1; min-height: 44px; }
.tb .cm-nuevo button { flex: none; justify-content: center; }
@media (max-width: 560px) { .tb .cm-nuevo { flex-direction: column; align-items: stretch; } }
.tb dialog.kb-porque { max-width: 520px; }
.tb dialog.kb-porque .pq-que { margin: 0 0 4px; font-size: 14.5px; color: var(--hg-texto-fuerte); font-weight: 600; line-height: 1.4; }
.tb dialog.kb-porque .pq-que span { font-size: 13px; font-weight: 500; color: var(--hg-texto-2); }
.tb dialog.kb-porque .pq-que b { color: var(--hg-texto-fuerte); }
/* teléfono (28-oct): el detalle cabe en la pantalla — encabezado y botones fijos, solo el cuerpo se desplaza */
@media (max-width: 560px) {
.tb dialog[open] { display: flex; flex-direction: column; max-height: calc(100dvh - 16px); }
.tb dialog .dcuerpo { max-height: none; flex: 1 1 auto; min-height: 0; }
.tb dialog .dpie { flex: none; }
}