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:
parent
dd160bd70c
commit
62c037f198
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
19
backend/lotes/2026-10-28_comentarios-pendientes.json
Normal file
19
backend/lotes/2026-10-28_comentarios-pendientes.json
Normal 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)." }
|
||||||
|
]
|
||||||
|
}
|
||||||
62
backend/tests-actas-documentos.sh
Executable file
62
backend/tests-actas-documentos.sh
Executable 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
53
backend/tests-comentarios.sh
Executable 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 ]
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 ----------
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user