Pendientes: elegir uno de tus comentarios como el «¿por qué?» al mover

Pedido de Leo (28-oct, 18:00): Michelle comenta antes de mover y luego tenía que escribir lo mismo otra vez. El «¿por
qué?» lista tus últimos comentarios del pendiente; si comentaste después del último cambio de columna, ya viene elegido.
PATCH /api/tarea acepta _nota_de (el «cuando» de un comentario tuyo; 400 si no existe o es de otra persona): la entrada
guarda «ref», no repite el texto en el hilo y lleva un extracto para la Actividad. En el hilo, el comentario elegido lleva
«→ Hecho»; un porqué pegado igual que un comentario anterior también se ve una sola vez. «Comentar» revisa el texto al
hacer clic. Pruebas: tests-comentarios.sh (26) y las demás; caso de Michelle en el navegador. DESPLIEGUE A14; lote
2026-10-28_comentarios-elegir.

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 18:17:17 -06:00
parent 62c037f198
commit e86b0a59fb
7 changed files with 149 additions and 20 deletions

View File

@ -597,6 +597,22 @@ sobre todo cuando algo pasó a Hecho sin estar terminado.
Desplegado el 28-oct: páginas con rsync después de la comparación con el servidor, backend con `kill -HUP` al maestro de 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'`. gunicorn. Para deshacer: `git revert` del commit, `./scripts/deploy.sh` y `ssh -t leo@git.helgen.online 'sudo systemctl restart actas-hbox'`.
## A14 · 28-oct: elegir uno de tus comentarios como el porqué
Lo pidió Leo (28-oct, 18:00): Michelle comenta antes de mover y luego el «¿por qué?» la hacía escribir lo mismo otra vez.
- `backend/app.py` (PATCH `/api/tarea`): `_nota_de` = el `cuando` de un comentario **de la misma persona** en ese
pendiente (si no existe o es de otra persona: 400). La entrada del movimiento guarda `ref` (sin `nota`, así no se
repite) y su texto para la Actividad lleva «por su comentario del dd/mm hh:mm: «extracto»». Con `_nota` además, guarda
las dos cosas.
- `docs/tableros/kanban.js` + `tablero.css`: el «¿por qué?» lista tus últimos 5 comentarios del pendiente; si comentaste
después del último cambio de columna, ese ya viene elegido (un clic en «Mover»). «Escribir otro motivo» sigue ahí. En el
hilo, el comentario elegido lleva la etiqueta «→ Hecho». El porqué pegado igual que un comentario anterior de la misma
persona también se muestra una sola vez. El orden se toma del historial, no de la hora (hay horas en UTC y en hora local).
- Pruebas: `bash backend/tests-comentarios.sh .` (26) y las demás; en el navegador, el caso de Michelle de punta a punta.
Desplegado igual que A13 (rsync de páginas tras la comparación; `kill -HUP` al maestro de gunicorn).
## B · Verificación después de desplegar ## B · Verificación después de desplegar
```bash ```bash

View File

@ -1535,6 +1535,16 @@ def tarea_modificar():
# comentario (28-oct): el «¿por qué?» al mover y los comentarios de la tarjeta; hasta 1000 caracteres. Va en el # 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. # 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) nota = _texto(d.get("_nota"), 1000)
# «_nota_de» (28-oct, Leo): el porqué es un comentario que la misma persona ya dejó en el pendiente (su «cuando»).
# No se repite el texto en el hilo: la entrada guarda «ref» y la Actividad lleva un extracto.
ref = _texto(d.get("_nota_de"), 40)
ref_txt = ""
if ref:
previo = next((h for h in reversed(base.get("historial") or []) if h.get("cuando") == ref and h.get("quien") == u), None)
if not previo:
return jsonify(ok=False, error="No encontré ese comentario tuyo en el pendiente; escribe el porqué."), 400
citado = str(previo.get("nota") or previo.get("cambio") or "").strip()
ref_txt = "por su comentario del " + ref[8:10] + "/" + ref[5:7] + " " + ref[11:16] + ": «" + (citado[:140] + "…" if len(citado) > 140 else citado) + "»"
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
@ -1545,9 +1555,12 @@ def tarea_modificar():
return jsonify(ok=True, tarea=t, aviso=aviso) return jsonify(ok=True, tarea=t, aviso=aviso)
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) extra = " — ".join(x for x in (nota, ref_txt if cambios else "") if x)
_hist(t, u, ("; ".join(cambios) + (" — " + extra if extra else "")) if cambios else nota)
if nota: if nota:
t["historial"][-1]["nota"] = nota t["historial"][-1]["nota"] = nota
if ref_txt and cambios:
t["historial"][-1]["ref"] = ref
_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,11 @@
{
"lote": "2026-10-28_comentarios-elegir",
"autor": "claude",
"motivo": "28-oct, 18:00 (Leo): Michelle comenta antes de mover y el «¿por qué?» la hacía escribir lo mismo. Ahora elige uno de sus comentarios.",
"fuente": "/tableros/pendientes/",
"cambios": [
{ "id": "t-pendientes-comentarios",
"si_estado": ["hecho"],
"nota": "28-oct 18:00 (Leo): al mover se puede elegir uno de tus comentarios como el porqué; si comentaste después del último movimiento ya viene elegido y no se repite en el hilo. Pruebas: 26 del servidor y el caso de Michelle en el navegador." }
]
}

19
backend/tests-comentarios.sh Executable file → Normal file
View File

@ -12,6 +12,7 @@ for u in leo adriana jesus michelle claude; do
echo "{\"id\":\"$u\",\"nombre\":\"$u\",\"area\":\"ingenieria\",\"activo\":true}" > $T/equipo/$u.json echo "{\"id\":\"$u\",\"nombre\":\"$u\",\"area\":\"ingenieria\",\"activo\":true}" > $T/equipo/$u.json
done 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 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
echo '{"id":"t-m","titulo":"v6 bloque E","responsable":"michelle","campeon":"michelle","estado":"en-curso","pista":"ingenieria","sitios":["qro"],"orden":1,"historial":[]}' > $T/tareas/t-m.json
python3 $REPO/backend/app.py > $T/app.log 2>&1 & python3 $REPO/backend/app.py > $T/app.log 2>&1 &
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8 APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8
U="http://127.0.0.1:$PORT" U="http://127.0.0.1:$PORT"
@ -45,6 +46,24 @@ 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 "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'))")" 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 "## elegir uno de sus comentarios como el porqué (28-oct)"
R=$(as michelle -X PATCH -d '{"_nota":"D12 quedó a 3.44 mm de U7.1 (se pedían 2 mm); L2 a 5 mm."}' "$U/api/tarea?id=t-m")
CUANDO=$(echo "$R" | python3 -c "import json,sys; print(json.load(sys.stdin)['tarea']['historial'][-1]['cuando'])")
sleep 1.1; as jesus -X PATCH -d '{"_nota":"Comentario de Jesús en el mismo pendiente."}' "$U/api/tarea?id=t-m" > /dev/null
OTRO=$(as leo "$U/api/tareas" | python3 -c "import json,sys; t=[x for x in json.load(sys.stdin)['tareas'] if x['id']=='t-m'][0]; print(t['historial'][-1]['cuando'])")
check "con el comentario de otra persona no" 'comentario tuyo' "$(as michelle -X PATCH -d "{\"estado\":\"hecho\",\"_nota_de\":\"$OTRO\"}" "$U/api/tarea?id=t-m")"
check "con un comentario que no existe no" 'comentario tuyo' "$(as michelle -X PATCH -d '{"estado":"hecho","_nota_de":"2020-01-01T00:00:00"}' "$U/api/tarea?id=t-m")"
check "…y no se movió" '"estado": "en-curso"' "$(as leo "$U/api/tareas" | python3 -c "import json,sys; t=[x for x in json.load(sys.stdin)['tareas'] if x['id']=='t-m'][0]; print(json.dumps({'estado': t['estado']}))")"
R=$(as michelle -X PATCH -d "{\"estado\":\"hecho\",\"_nota_de\":\"$CUANDO\"}" "$U/api/tarea?id=t-m")
check "con su propio comentario se mueve" '"estado":"hecho"' "$R"
check "la entrada guarda «ref» con su «cuando»" "\"ref\": \"$CUANDO\"" "$(echo "$R" | ultimo)"
check "y no repite el texto como comentario (sin «nota»)" 'None' "$(echo "$R" | python3 -c "import json,sys; print(json.load(sys.stdin)['tarea']['historial'][-1].get('nota'))")"
check "la Actividad lleva el extracto" 'por su comentario del' "$(echo "$R" | ultimo)"
R=$(as michelle -X PATCH -d "{\"estado\":\"en-curso\",\"_nota_de\":\"$CUANDO\",\"_nota\":\"Leo pidió revisar L2 otra vez.\"}" "$U/api/tarea?id=t-m")
check "citar y además escribir: guarda las dos cosas" '"nota": "Leo pidió revisar L2 otra vez."' "$(echo "$R" | ultimo)"
check "…con su «ref»" "\"ref\": \"$CUANDO\"" "$(echo "$R" | ultimo)"
check "citar sin cambiar nada no deja rastro" '"sin_cambios":true' "$(as michelle -X PATCH -d "{\"_nota_de\":\"$CUANDO\"}" "$U/api/tarea?id=t-m")"
echo "## la Actividad sigue leyendo el historial" echo "## la Actividad sigue leyendo el historial"
check "la Actividad muestra el comentario" 'a dos proveedores' "$(as leo "$U/api/actividad?dias=2")" check "la Actividad muestra el comentario" 'a dos proveedores' "$(as leo "$U/api/actividad?dias=2")"

View File

@ -36,7 +36,10 @@
Comentarios (28-oct, Leo): el detalle tiene «Comentarios» (hilo + caja; PATCH solo con «_nota», cualquiera con 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 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 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»). */ La nota va en «_nota» del mismo PATCH y queda en el historial («nota»). Si la persona ya había comentado, puede
elegir uno de sus comentarios como el porqué («_nota_de» = su «cuando»; la entrada guarda «ref»): no se repite en el
hilo, el comentario elegido lleva la etiqueta «→ Hecho». Si comentó después del último cambio de columna, ese viene
ya elegido. El orden es el del historial, no la hora (hay horas en UTC y en hora local). */
(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']];
@ -186,10 +189,16 @@
const NOMBRE_ESTADO = Object.fromEntries(ESTADOS); const NOMBRE_ESTADO = Object.fromEntries(ESTADOS);
const NO_ES_COMENTARIO = /^(creado en |borró el pendiente|restaur)/i; const NO_ES_COMENTARIO = /^(creado en |borró el pendiente|restaur)/i;
function comentarios(t) { function comentarios(t) {
const out = []; const out = [], porClave = {};
(t.historial || []).forEach(x => { (t.historial || []).forEach((x, i) => {
const c = String(x.cambio || ''); const c = String(x.cambio || '');
const mv = c.match(/estado: ([a-z-]+) → ([a-z-]+)/); const mv = c.match(/estado: ([a-z-]+) → ([a-z-]+)/);
// movió citando uno de sus comentarios: la etiqueta va en ese comentario, sin repetir el texto
if (x.ref && !x.nota) {
const o = porClave[x.ref + '|' + x.quien];
if (o && mv) (o.movs = o.movs || []).push({ a: mv[2], quien: x.quien, cuando: x.cuando });
return;
}
let texto = x.nota || ''; let texto = x.nota || '';
if (!texto) { if (!texto) {
if (!c || NO_ES_COMENTARIO.test(c)) return; if (!c || NO_ES_COMENTARIO.test(c)) return;
@ -197,10 +206,20 @@
if (flecha < 0) texto = c; if (flecha < 0) texto = c;
else { const k = c.indexOf(' — ', flecha); if (k < 0) return; texto = c.slice(k + 3); } 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] }); // el porqué pegado igual que un comentario suyo de antes (como se hacía antes del 28-oct en la tarde): una sola vez
if (mv && texto.trim()) {
const igual = out.slice().reverse().find(o => o.quien === x.quien && !o.a && o.texto === texto.trim());
if (igual) { (igual.movs = igual.movs || []).push({ a: mv[2], quien: x.quien, cuando: x.cuando }); return; }
}
if (texto.trim()) {
const o = { quien: x.quien, cuando: x.cuando, texto: texto.trim(), de: mv && mv[1], a: mv && mv[2], i };
out.push(o); porClave[x.cuando + '|' + x.quien] = o;
}
}); });
return out; return out;
} }
// posición en el historial del último cambio de columna (-1 si nunca se movió)
const ultimoMovimiento = t => { const h = t.historial || []; for (let i = h.length - 1; i >= 0; i--) if (/estado: [a-z-]+ → /.test(String(h[i].cambio || ''))) return i; return -1; };
// cuándo pide el «¿por qué?» un cambio de columna (null = se mueve sin preguntar) // cuándo pide el «¿por qué?» un cambio de columna (null = se mueve sin preguntar)
function reglaMover(desde, hacia) { function reglaMover(desde, hacia) {
if (desde === hacia) return null; if (desde === hacia) return null;
@ -624,12 +643,15 @@
} }
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 // 28-oct: algunos cambios de columna piden «¿por qué?» antes de moverse; «Cancelar» la deja donde estaba
let nota = ''; let pq = null;
const regla = mismaCol ? null : reglaMover(t.estado, estado); const regla = mismaCol ? null : reglaMover(t.estado, estado);
if (regla) { if (regla) {
nota = await porQue(t, estado, regla); pq = await porQue(t, estado, regla);
if (nota === null) { pintar(); return; } if (pq === null) { pintar(); return; }
} }
const extra = {};
if (pq && pq.nota) extra._nota = pq.nota;
if (pq && pq.ref) extra._nota_de = pq.ref;
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);
@ -637,9 +659,9 @@
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', Object.assign({ estado, orden: ids.indexOf(id) }, nota ? { _nota: nota } : {}))); if (!mismaCol) await api('/api/tarea?id=' + encodeURIComponent(id), json('PATCH', Object.assign({ estado, orden: ids.indexOf(id) }, extra)));
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] + '.' + (nota ? ' El porqué quedó en sus comentarios.' : ''), true); if (!mismaCol) mensaje('«' + corto(t.titulo, 41) + '» pasó a ' + ESTADOS.find(e => e[0] === estado)[1] + '.' + (pq ? ' 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); }
} }
@ -661,20 +683,51 @@
const lab = el('label', { text: regla.pregunta }); const lab = el('label', { text: regla.pregunta });
const txt = el('textarea', { rows: '3', maxlength: '1000', placeholder: regla.ph, 'aria-required': 'true' }); 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; 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.' }); // 28-oct (Leo): si ya comentaste en este pendiente, elige uno de tus comentarios en vez de repetirlo
cuerpo.append(lab, txt, ayuda); dlgPq.append(cuerpo); const yo = norm(user), desde = ultimoMovimiento(t);
const mios = comentarios(t).filter(c => norm(c.quien) === yo).slice(-5).reverse();
let elegido = null;
const ops = el('div', { class: 'pq-ops', role: 'radiogroup', 'aria-label': 'Tus comentarios' });
const radios = [];
function elegir(c) {
elegido = c;
radios.forEach(r => { r.inp.checked = r.c === c; r.lab.classList.toggle('on', r.c === c); });
txt.placeholder = c ? 'Agrega algo más (opcional)' : regla.ph;
txt.setAttribute('aria-required', c ? 'false' : 'true');
listoPara();
}
if (mios.length) {
cuerpo.append(lab);
mios.forEach(c => {
const l = el('label', { class: 'pq-op' });
const inp = el('input', { type: 'radio', name: 'kb-pq-op-' + t.id });
l.append(inp, el('span', { class: 'pq-op-c' }, el('span', { class: 'pq-op-f', text: 'Tu comentario · ' + fmt(c.cuando) }), el('span', { class: 'pq-op-t', text: c.texto })));
inp.addEventListener('change', () => elegir(c));
radios.push({ inp, lab: l, c }); ops.append(l);
});
const lOtro = el('label', { class: 'pq-op' });
const inpOtro = el('input', { type: 'radio', name: 'kb-pq-op-' + t.id });
lOtro.append(inpOtro, el('span', { class: 'pq-op-c' }, el('span', { class: 'pq-op-t', text: 'Escribir otro motivo' })));
inpOtro.addEventListener('change', () => { elegir(null); txt.focus(); });
radios.push({ inp: inpOtro, lab: lOtro, c: null }); ops.append(lOtro);
cuerpo.append(ops, txt);
} else cuerpo.append(lab, txt);
const ayuda = el('div', { class: 'd-nota', text: mios.length ? 'El comentario que elijas queda marcado con este cambio; no se repite.' : 'Queda en los comentarios del pendiente, con tu nombre.' });
cuerpo.append(ayuda); dlgPq.append(cuerpo);
const pie = el('div', { class: 'dpie' }); const pie = el('div', { class: 'dpie' });
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' });
const ok = el('button', { type: 'button', text: 'Mover a ' + (NOMBRE_ESTADO[estado] || estado) }); const ok = el('button', { type: 'button', text: 'Mover a ' + (NOMBRE_ESTADO[estado] || estado) });
const listoPara = () => { ok.disabled = txt.value.trim().length < 5; }; function listoPara() { ok.disabled = !elegido && txt.value.trim().length < 5; }
txt.addEventListener('input', listoPara); listoPara(); txt.addEventListener('input', listoPara);
// si comentaste después del último cambio de columna, ese comentario ya viene elegido
if (mios.length && mios[0].i > desde) elegir(mios[0]); else if (mios.length) elegir(null); else listoPara();
txt.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !ok.disabled) ok.click(); }); txt.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !ok.disabled) ok.click(); });
cancelar.addEventListener('click', () => dlgPq.close()); cancelar.addEventListener('click', () => dlgPq.close());
ok.addEventListener('click', () => { valor = txt.value.trim(); dlgPq.close(); }); ok.addEventListener('click', () => { valor = { nota: txt.value.trim(), ref: elegido ? elegido.cuando : '' }; dlgPq.close(); });
pie.append(cancelar, ok); dlgPq.append(pie); pie.append(cancelar, ok); dlgPq.append(pie);
dlgPq.addEventListener('close', function fin() { dlgPq.removeEventListener('close', fin); listo(valor); }); dlgPq.addEventListener('close', function fin() { dlgPq.removeEventListener('close', fin); listo(valor); });
if (typeof dlgPq.showModal === 'function') dlgPq.showModal(); else dlgPq.setAttribute('open', ''); if (typeof dlgPq.showModal === 'function') dlgPq.showModal(); else dlgPq.setAttribute('open', '');
setTimeout(() => txt.focus(), 50); setTimeout(() => (elegido ? ok : txt).focus(), 50);
}); });
} }
@ -693,6 +746,8 @@
const cab = el('div', { class: 'cm-cab' }); 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) })); 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) })); if (c.a) cab.append(el('span', { class: 'cm-mv e-' + c.a, text: '→ ' + (NOMBRE_ESTADO[c.a] || c.a) }));
(c.movs || []).forEach(m => cab.append(el('span', { class: 'cm-mv e-' + m.a, title: 'Movido con este comentario como porqué · ' + fmt(m.cuando),
text: '→ ' + (NOMBRE_ESTADO[m.a] || m.a) + (norm(m.quien) !== norm(c.quien) ? ' · ' + nombreCorto(norm(m.quien)) : '') })));
cuerpo.append(cab, el('div', { class: 'cm-t', text: c.texto })); cuerpo.append(cab, el('div', { class: 'cm-t', text: c.texto }));
d.append(cuerpo); d.append(cuerpo);
return d; return d;
@ -714,10 +769,12 @@
'aria-label': 'Nuevo comentario' }); 'aria-label': 'Nuevo comentario' });
const btn = el('button', { type: 'button', text: 'Comentar' }); const btn = el('button', { type: 'button', text: 'Comentar' });
const msg = el('div', { class: 'msg' }); const msg = el('div', { class: 'msg' });
const listoPara = () => { btn.disabled = !user || !txt.value.trim(); }; // el botón no depende de los eventos de teclado (autocompletado, dictado, pegar): revisa el texto al hacer clic
txt.disabled = !user; txt.addEventListener('input', listoPara); listoPara(); const listoPara = () => { btn.disabled = !user; };
txt.disabled = !user; listoPara();
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const nota = txt.value.trim(); if (!nota) return; const nota = txt.value.trim();
if (!nota) { msg.className = 'msg err'; msg.textContent = 'Escribe el comentario primero.'; txt.focus(); return; }
btn.disabled = true; btn.disabled = true;
try { try {
const r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', { _nota: nota })); const r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', { _nota: nota }));

View File

@ -18,7 +18,7 @@ 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><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><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. Si ya lo explicaste en un comentario, <b>elige tu comentario</b> en vez de escribirlo otra vez (si comentaste después del último movimiento, ya viene elegido): queda marcado con el cambio y no se repite. «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>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; al moverlo te pregunta 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>

View File

@ -777,3 +777,16 @@
.tb dialog .dcuerpo { max-height: none; flex: 1 1 auto; min-height: 0; } .tb dialog .dcuerpo { max-height: none; flex: 1 1 auto; min-height: 0; }
.tb dialog .dpie { flex: none; } .tb dialog .dpie { flex: none; }
} }
/* «¿por qué?»: elegir uno de tus comentarios (28-oct) */
.tb dialog.kb-porque .pq-ops { display: grid; gap: 6px; margin: 0 0 8px; max-height: 240px; overflow: auto; }
.tb dialog.kb-porque .pq-op {
display: flex; gap: 9px; align-items: flex-start; margin: 0; padding: 8px 10px; cursor: pointer;
border: 1px solid var(--hg-linea-2); border-radius: 9px; background: var(--hg-plano); color: var(--hg-texto); font-weight: 500;
}
.tb dialog.kb-porque .pq-op input { width: auto; margin-top: 3px; flex: none; accent-color: var(--hg-curso); }
.tb dialog.kb-porque .pq-op.on { border-color: color-mix(in srgb, var(--hg-curso) 55%, transparent); background: var(--hg-curso-bg); }
.tb dialog.kb-porque .pq-op:focus-within { outline: 2px solid var(--hg-foco); outline-offset: 1px; }
.tb dialog.kb-porque .pq-op-c { display: grid; gap: 2px; min-width: 0; }
.tb dialog.kb-porque .pq-op-f { font-size: 12px; color: var(--hg-texto-3); font-weight: 600; }
.tb dialog.kb-porque .pq-op-t { font-size: 13.5px; line-height: 1.4; color: var(--hg-texto); white-space: pre-wrap; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }