diff --git a/DESPLIEGUE.md b/DESPLIEGUE.md index 57bc505..7a7fd1f 100644 --- a/DESPLIEGUE.md +++ b/DESPLIEGUE.md @@ -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, PDF y texto, y descarga lo demás con su nombre original. - `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 `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'`. +## 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 ```bash diff --git a/backend/app.py b/backend/app.py index d0b153e..9208a29 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1532,7 +1532,9 @@ def tarea_modificar(): if k == "orden": continue # la posición dentro de la columna no ensucia el historial 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 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 @@ -1544,6 +1546,8 @@ def tarea_modificar(): t["vencida"] = _vencida(t) return jsonify(ok=True, tarea=t, sin_cambios=True, aviso=aviso) _hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota) + if nota: + t["historial"][-1]["nota"] = nota _guardar_tarea(t) t["vencida"] = _vencida(t) return jsonify(ok=True, tarea=t, aviso=aviso) diff --git a/backend/lotes/2026-10-28_comentarios-pendientes.json b/backend/lotes/2026-10-28_comentarios-pendientes.json new file mode 100644 index 0000000..83e6d1e --- /dev/null +++ b/backend/lotes/2026-10-28_comentarios-pendientes.json @@ -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)." } + ] +} diff --git a/backend/tests-actas-documentos.sh b/backend/tests-actas-documentos.sh new file mode 100755 index 0000000..5fa1513 --- /dev/null +++ b/backend/tests-actas-documentos.sh @@ -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 [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", "") +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"") +open(os.path.join(d, "pagina.html"), "wb").write(b"") +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 ] diff --git a/backend/tests-comentarios.sh b/backend/tests-comentarios.sh new file mode 100755 index 0000000..1e77356 --- /dev/null +++ b/backend/tests-comentarios.sh @@ -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 [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 ] diff --git a/docs/tableros/juntas.md b/docs/tableros/juntas.md index 67c3886..754bab6 100644 --- a/docs/tableros/juntas.md +++ b/docs/tableros/juntas.md @@ -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. 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**. -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. ## Cadencia diff --git a/docs/tableros/kanban.js b/docs/tableros/kanban.js index 0067fe4..bb1d4fe 100644 --- a/docs/tableros/kanban.js +++ b/docs/tableros/kanban.js @@ -32,7 +32,11 @@ 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 (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 () { 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']]; @@ -76,7 +80,8 @@ 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', 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) => ''; // Í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) }); + // ---- 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) { opts = opts || {}; let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = [], muevenSiempre = null; @@ -370,6 +410,13 @@ const pie = el('div', { class: 'pie' }); pie.append(t.responsable ? avatares(t) : el('span', { class: 'vacio', text: 'Sin responsable' })); 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') + '' + nCom + '' }); + cm.addEventListener('click', ev => { ev.stopPropagation(); abrir(t, null, true); }); + der.append(cm); + } const f = fechaDe(t); 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) @@ -576,6 +623,13 @@ return; } 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); columna.splice(Math.max(0, Math.min(idx, columna.length)), 0, t); const ids = columna.map(x => x.id); @@ -583,18 +637,107 @@ const prev = { estado: t.estado, orden: t.orden }; t.estado = estado; columna.forEach((x, i) => x.orden = i); pintar(); 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 })); - 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(); } 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)) + '»
' + esc(NOMBRE_ESTADO[t.estado] || t.estado) + ' → ' + esc(NOMBRE_ESTADO[estado] || estado) + '' })); + 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') + 'Comentarios' })); + 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 ---------- const dlg = el('dialog', { class: 'kb-dlg', 'aria-label': 'Pendiente' }); contenedor.append(dlg); 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; 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'] }; @@ -661,6 +804,8 @@ : 'Solo ' + quienMueve(t) + ', campeón de este pendiente, o un administrador lo cambia de columna o de campeón.' })); cuerpo.append(cMv); } + const secCom = nuevo ? null : seccionComentarios(t); + if (secCom) cuerpo.append(secCom); cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc)); const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista)); if (nuevo) g1.append(campo('Estado', fEst)); @@ -745,7 +890,11 @@ }); pie.append(cancelar, guardar); 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 ---------- diff --git a/docs/tableros/pendientes.md b/docs/tableros/pendientes.md index 7657930..0270da4 100644 --- a/docs/tableros/pendientes.md +++ b/docs/tableros/pendientes.md @@ -18,10 +18,11 @@ hide:
  • Cada tarjeta del tablero es un pendiente (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.
  • 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 ← → de la tarjeta o los botones de Columna en el detalle; en el teléfono, Mover a…. El tablero anota quién lo movió y cuándo.
  • Campeón: 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.
  • -
  • Toca el título para ver el detalle: descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda.
  • +
  • ¿Por qué? (28-oct): algunos cambios de columna preguntan antes de moverse y lo que escribas queda en los comentarios con tu nombre. A Hecho: la evidencia (el acta, la liga o el archivo). Al reabrir algo que estaba en 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.
  • +
  • Toca el título para ver el detalle: comentarios, 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).
  • Nuevo pendiente: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».
  • -
  • Columna Bloqueado: solo si no puedes avanzar. Escribe en la descripción qué falta y quién lo destraba.
  • -
  • Al terminar, muévelo a Hecho. No lo borres: solo el administrador borra, y el historial sirve de bitácora.
  • +
  • Columna Bloqueado: solo si no puedes avanzar; al moverlo te pregunta qué falta y quién lo destraba.
  • +
  • Al terminar, muévelo a Hecho con su evidencia. No lo borres: solo el administrador borra, y el historial sirve de bitácora.
  • Rojo = vencido. Filtra por pista, responsable o versión de tarjeta; los filtros no cambian nada en el servidor.
  • Sitio: 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); Por sitio muestra una fila por sitio. Al crear un pendiente con un sitio filtrado, ese sitio ya viene marcado. La dirección (?sitio=eit) se puede compartir.
  • Por persona 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 (?vista=persona, ?mios=1) se puede compartir tal cual.
  • diff --git a/docs/tableros/tablero.css b/docs/tableros/tablero.css index 1cded56..9d7cc9b 100644 --- a/docs/tableros/tablero.css +++ b/docs/tableros/tablero.css @@ -735,3 +735,45 @@ .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 .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; } +}