Pendientes: campeón por pendiente, flechas ← →, franja para soltar y columna en el detalle

Pedido de Leo (27-oct): mover un pendiente a otra columna sin arrastrar y bajar a la vez,
cambiar la columna desde el detalle, y un solo campeón por pendiente que es quien lo mueve.

- backend/app.py: campo «campeon» (uno de los responsables; por omisión el primero).
  PATCH /api/tarea y POST /api/tareas/orden solo cambian la columna o el campeón si quien
  escribe es el campeón, un administrador o TAREAS_MUEVEN_SIEMPRE (claude, por los lotes).
  GET /api/tareas trae campeon y mueven_siempre.
- kanban.js / tablero.css: avatar del campeón primero y con anillo; flechas ← → en la
  tarjeta; franja fija arriba con las 4 columnas mientras se arrastra; botones de Columna
  y selector de Campeón en el detalle; tarjetas de otros sin arrastre ni flechas.
- tablero.py y lotes/README.md: «campeon» se puede poner en un lote.
- tests-campeon.sh (25 pruebas); tests-1014.sh: el PATCH de bob ya no cambia la columna.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SfRSvyewSyFLfK9mjbGL8
This commit is contained in:
Leopoldo Rodríguez 2026-10-27 21:15:48 -06:00
parent 158c9fdd13
commit 1e63a7c738
8 changed files with 277 additions and 24 deletions

View File

@ -1248,7 +1248,7 @@ def usuario_borrar():
# ------------------------------------------------------------------ tareas (tablero)
COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite", "version_tarjeta", "sitios",
COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "responsable_ids", "campeon", "fecha_limite", "version_tarjeta", "sitios",
"orden", "etiquetas", "bloqueada_por", "creado_por", "creado_en", "modificado_por", "modificado_en"]
@ -1273,6 +1273,32 @@ def _ids_responsable(texto, miembros=None):
return ids, libres
# Campeón (27-oct, Leo): aunque un pendiente tenga varios responsables, UNA persona lo mueve entre columnas
# y responde por él. Campo «campeon» = id de uno de los responsables; sin campo (pendientes de antes), el primero
# de la lista. Lo pueden mover y cambiar de campeón: el campeón, los administradores (ACTAS_ADMINS) y
# TAREAS_MUEVEN_SIEMPRE (por omisión claude: los lotes que aplica Leo y el agente diario).
TAREAS_MUEVEN_SIEMPRE = {u.strip().lower() for u in os.environ.get("TAREAS_MUEVEN_SIEMPRE", "claude").split(",") if u.strip()}
def _campeon(t, miembros=None):
ids = _ids_responsable(t.get("responsable"), miembros)[0] if t.get("responsable") else list(t.get("responsable_ids") or [])
c = _norm_id(t.get("campeon") or "")
return c if c in ids else (ids[0] if ids else "")
def _puede_mover(t, u, miembros=None):
u = (u or "").strip().lower()
c = _campeon(t, miembros)
return bool(u) and (not c or u == c or u in ADMINS or u in TAREAS_MUEVEN_SIEMPRE)
def _no_puede_mover(t, que="moverlo de columna"):
c = _campeon(t)
nombre = (_miembros().get(c) or {}).get("nombre") or c
return jsonify(ok=False, campeon=c, error=f"Solo {nombre}, campeón de este pendiente, o un administrador puede {que}. "
f"Si te toca a ti, pide que te pasen el campeón."), 403
def _tarea_valida(d, base=None):
"""Normaliza los campos de una tarjeta del tablero. Devuelve (tarea, error).
Con base=None es un alta (todos los campos con su valor por defecto); con base es un PATCH
@ -1315,6 +1341,16 @@ def _tarea_valida(d, base=None):
+ ". Se guarda como texto; da de alta a la persona en Equipo para que cuente en su lista.")
elif base is not None and "responsable_ids" not in t:
t["responsable_ids"] = _ids_responsable(t.get("responsable"))[0] # pendientes de antes del 14-oct
if "campeon" in d:
c = _norm_id(d.get("campeon") or "")
ids = t.get("responsable_ids") or []
if c and c not in ids:
return None, "El campeón tiene que ser uno de los responsables del pendiente (del equipo)."
pon("campeon", c or (ids[0] if ids else ""))
elif alta or "responsable" in d:
ids = t.get("responsable_ids") or []
if t.get("campeon") not in ids: # alta, o el campeón salió de los responsables: el primero de la lista
pon("campeon", ids[0] if ids else "")
if alta or "fecha_limite" in d:
f = d.get("fecha_limite")
if f not in (None, "") and not _fecha_o_none(f):
@ -1389,6 +1425,7 @@ def tareas_listar():
# se da de alta después, sus pendientes ya cuentan como suyos sin volver a guardarlos. Nunca quita a nadie:
# los miembros no se borran, solo se desactivan.
t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0]
t["campeon"] = _campeon(t, miembros)
if not t.get("sitios"): # pendientes de antes del 22-oct: Querétaro
t["sitios"] = list(SITIO_OMISION)
if sitio and not sitio.intersection(t["sitios"]):
@ -1409,7 +1446,7 @@ def tareas_listar():
return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS, equipo=equipo,
versiones=VERSIONES_TARJETA, sitios=[{"id": i, "nombre": n, "corto": c} for i, n, c in SITIOS_TAREA],
admin=is_admin(), user=current_user(), puede_borrar=_puede_borrar_tarea(),
borran=sorted(ADMINS | TAREAS_BORRAN))
borran=sorted(ADMINS | TAREAS_BORRAN), mueven_siempre=sorted(ADMINS | TAREAS_MUEVEN_SIEMPRE))
@app.post("/api/tareas")
@ -1465,6 +1502,12 @@ def tarea_modificar():
t, err = _tarea_valida(d, base)
if err:
return jsonify(ok=False, error=err), 400
# campeón (27-oct): solo él (o un administrador) cambia la columna o pasa el campeón a otra persona
if not _puede_mover(base, u):
if t.get("estado") != base.get("estado"):
return _no_puede_mover(base)
if _campeon(t) != _campeon(base):
return _no_puede_mover(base, "cambiar el campeón")
aviso = t.pop("_aviso", None)
cambios = []
for k in d:
@ -1501,11 +1544,15 @@ def tareas_orden():
if estado not in ESTADOS or not ids:
return jsonify(ok=False, error="Se esperaba {estado, ids:[...]}"), 400
n = 0
negados = []
for i, rid in enumerate(ids):
ruta = _ruta_registro(TAREAS_DIR, rid)
t = _leer_json(ruta)
if not t:
continue
if t.get("estado") != estado and not _puede_mover(t, u):
negados.append(rid) # campeón (27-oct): no se cambia de columna; el orden de las demás sí
continue
if t.get("estado") != estado or t.get("orden") != i:
if t.get("estado") != estado:
_hist(t, u, f"estado: {t.get('estado')} → {estado}")
@ -1517,7 +1564,7 @@ def tareas_orden():
n += 1
if n:
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
return jsonify(ok=True, actualizadas=n)
return jsonify(ok=True, actualizadas=n, negados=negados)
# Quién borra pendientes (22-oct, Leo): los administradores (ACTAS_ADMINS) y TAREAS_BORRAN, por omisión

View File

@ -67,7 +67,7 @@ y queda en el historial del pendiente con su autor y su nota. En la página de i
| `id` / `buscar` | cambio | El pendiente: por su id, o por un texto de su título, sin importar acentos, mayúsculas ni signos. `buscar` también puede ser una lista: el título debe tener todos los textos y `"-texto"` quiere decir «que no lo tenga» (`["piloto", "v5.6", "-banco"]`). Tiene que coincidir con **uno solo**, primero entre los abiertos |
| `todos` | cambio | Con `buscar`: aplica el cambio a **todos** los abiertos que coincidan (por ejemplo, cerrar todas las «pasada 3») |
| `si_estado` | cambio | Solo aplica si el pendiente está en uno de estos estados. Por omisión: por hacer, en curso o bloqueado |
| `poner` | cambio | Los campos que cambian: `titulo`, `descripcion`, `pista`, `estado`, `responsable`, `fecha_limite`, `enlaces`, `etiquetas`, `bloqueada_por`, `version_tarjeta`. Lo que ya tiene ese valor no se manda |
| `poner` | cambio | Los campos que cambian: `titulo`, `descripcion`, `pista`, `estado`, `responsable`, `campeon` (27-oct: uno de los responsables; es quien mueve el pendiente entre columnas), `fecha_limite`, `enlaces`, `etiquetas`, `bloqueada_por`, `version_tarjeta`. Lo que ya tiene ese valor no se manda |
| `nota` | cambio | Obligatoria. Comentario en el historial; sin `poner`, el cambio es solo el comentario |
| `crear` | cambio | Pendiente nuevo, con los mismos campos que el formulario del sitio más un `id` fijo (`t-…`, `d-…`): `titulo`, `pista`, `responsable` obligatorios |
| `si_no_hay` | cambio | Con `crear`: no lo crea si hay un pendiente abierto cuyo título contenga alguno de estos textos |

View File

@ -44,7 +44,7 @@ LECTOR = "claude" # X-Remote-User para las lecturas (no cambia nada; queda cla
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
ABIERTOS = ("por-hacer", "en-curso", "bloqueado")
NOMBRE = {"por-hacer": "Por hacer", "en-curso": "En curso", "bloqueado": "Bloqueado", "hecho": "Hecho"}
CAMPOS = ("titulo", "descripcion", "pista", "estado", "responsable", "fecha_limite", "enlaces", "etiquetas",
CAMPOS = ("titulo", "descripcion", "pista", "estado", "responsable", "campeon", "fecha_limite", "enlaces", "etiquetas",
"bloqueada_por", "version_tarjeta", "sitios")
LOTE_ID = re.compile(r"^\d{4}-\d{2}-\d{2}_[a-z0-9][a-z0-9-]{1,60}$")
TAREA_ID = re.compile(r"^[a-z0-9][a-z0-9-]{2,59}$")

View File

@ -93,7 +93,8 @@ check "crear con ids (y 'Jesús' con acento → jesus)" '"responsable_ids":["mic
check "texto libre → se acepta con aviso" "sin registrar en el equipo: Proveedor X" "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x2","titulo":"y","responsable":"leo / Proveedor X"}')"
check "lista como array también vale" '"responsable":"alan, daniel"' "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x3","titulo":"z","responsable":["alan","daniel"]}')"
check "PATCH responsable vacío → 400" "Falta el responsable" "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"responsable":""}')"
check "PATCH sin tocar responsable sigue funcionando" '"estado":"en-curso"' "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"estado":"en-curso"}')"
# 27-oct: bob no es el campeón de t-x1 (es michelle): se prueba con la fecha, no con la columna (ver tests-campeon.sh)
check "PATCH sin tocar responsable sigue funcionando" '"fecha_limite":"2026-11-30"' "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"fecha_limite":"2026-11-30"}')"
check "filtro por id" '"id":"t-x1"' "$(curl -s "$U/api/tareas?responsable=jesus")"
check "filtro con acento" '"id":"t-x1"' "$(curl -s "$U/api/tareas?responsable=jes%C3%BAs")"
check "GET tareas trae el mapa equipo" '"michelle":{' "$(curl -s "$U/api/tareas?responsable=michelle" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["equipo"], separators=(",",":")))')"

60
backend/tests-campeon.sh Normal file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Pruebas del 27-oct (Leo): campeón de cada pendiente — solo él, los administradores y TAREAS_MUEVEN_SIEMPRE (claude)
# lo cambian de columna o le pasan el campeón a otra persona. Arranca app.py con datos temporales; no toca el servidor.
# Uso: bash backend/tests-campeon.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8098}
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
# t-a: de antes (sin campo campeon): el campeón es el primero, adriana
echo '{"id":"t-a","titulo":"Cotizar gabinetes","responsable":"adriana, jesus","estado":"por-hacer","pista":"compras","sitios":["qro"],"orden":0,"historial":[]}' > $T/tareas/t-a.json
echo '{"id":"t-b","titulo":"Solo de Jesús","responsable":"jesus","estado":"por-hacer","pista":"compras","sitios":["qro"],"orden":1,"historial":[]}' > $T/tareas/t-b.json
echo '{"id":"t-c","titulo":"Texto libre","responsable":"Proveedor X","estado":"por-hacer","pista":"compras","sitios":["qro"],"orden":2,"historial":[]}' > $T/tareas/t-c.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}"; }
campo() { python3 -c "import json,sys; d=json.load(sys.stdin); t=[x for x in d['tareas'] if x['id']=='$1'][0]; print(t.get('$2'))"; }
echo "## campeón por omisión"
check "pendiente viejo: el primero de la lista" "adriana" "$(as leo $U/api/tareas | campo t-a campeon)"
check "GET /api/tareas trae mueven_siempre" '"mueven_siempre":["claude","leo"]' "$(as leo $U/api/tareas)"
check "alta: campeón = primer responsable" '"campeon":"michelle"' "$(as leo -X POST -d '{"id":"t-n","titulo":"Nuevo","responsable":"michelle, jesus","pista":"ingenieria"}' $U/api/tareas)"
check "alta con campeón elegido" '"campeon":"jesus"' "$(as leo -X POST -d '{"id":"t-n2","titulo":"Nuevo 2","responsable":"michelle, jesus","campeon":"jesus","pista":"ingenieria"}' $U/api/tareas)"
check "campeón que no es responsable: error" "tiene que ser uno de los responsables" "$(as leo -X POST -d '{"id":"t-n3","titulo":"Nuevo 3","responsable":"michelle","campeon":"jesus","pista":"ingenieria"}' $U/api/tareas)"
echo "## quién mueve"
check "otro responsable (jesus) no mueve t-a" "Solo adriana, campe" "$(as jesus -X PATCH -d '{"estado":"en-curso"}' "$U/api/tarea?id=t-a")"
check "…y el pendiente no cambió" "por-hacer" "$(as leo $U/api/tareas | campo t-a estado)"
check "el campeón (adriana) sí mueve" '"estado":"en-curso"' "$(as adriana -X PATCH -d '{"estado":"en-curso"}' "$U/api/tarea?id=t-a")"
check "un administrador (leo) sí mueve" '"estado":"bloqueado"' "$(as leo -X PATCH -d '{"estado":"bloqueado"}' "$U/api/tarea?id=t-a")"
check "claude (lotes) sí mueve" '"estado":"en-curso"' "$(as claude -X PATCH -d '{"estado":"en-curso","_nota":"lote"}' "$U/api/tarea?id=t-a")"
check "alguien que no es responsable no mueve t-b" "403" "$(as michelle -o /dev/null -w '%{http_code}' -X PATCH -d '{"estado":"hecho"}' "$U/api/tarea?id=t-b")"
check "sin campeón (solo texto libre) cualquiera mueve" '"estado":"en-curso"' "$(as michelle -X PATCH -d '{"estado":"en-curso"}' "$U/api/tarea?id=t-c")"
check "los demás campos sí los edita otro responsable" '"fecha_limite":"2026-11-05"' "$(as jesus -X PATCH -d '{"fecha_limite":"2026-11-05"}' "$U/api/tarea?id=t-a")"
echo "## pasar el campeón"
check "otro responsable no se puede quedar el campeón" "puede cambiar el campe" "$(as jesus -X PATCH -d '{"campeon":"jesus"}' "$U/api/tarea?id=t-a")"
check "quitar al campeón de los responsables tampoco" "puede cambiar el campe" "$(as jesus -X PATCH -d '{"responsable":"jesus"}' "$U/api/tarea?id=t-a")"
check "agregar a alguien sin tocar al campeón sí" '"campeon":"adriana"' "$(as jesus -X PATCH -d '{"responsable":"adriana, jesus, michelle"}' "$U/api/tarea?id=t-a")"
check "el campeón lo pasa a jesus" '"campeon":"jesus"' "$(as adriana -X PATCH -d '{"campeon":"jesus"}' "$U/api/tarea?id=t-a")"
check "queda en el historial" "campeon: adriana → jesus" "$(cat $T/tareas/t-a.json)"
check "ahora adriana ya no mueve" "Solo jesus, campe" "$(as adriana -X PATCH -d '{"estado":"hecho"}' "$U/api/tarea?id=t-a")"
check "y jesus sí" '"estado":"hecho"' "$(as jesus -X PATCH -d '{"estado":"hecho"}' "$U/api/tarea?id=t-a")"
check "si el campeón sale de los responsables (admin), pasa al primero" '"campeon":"michelle"' "$(as leo -X PATCH -d '{"responsable":"michelle, adriana"}' "$U/api/tarea?id=t-a")"
echo "## orden de columna (arrastre)"
r=$(as adriana -X POST -d '{"estado":"hecho","ids":["t-b","t-c"]}' $U/api/tareas/orden)
check "orden: a t-b (de jesus) no lo cambia de columna" '"negados":["t-b"]' "$r"
check "…t-b sigue en por-hacer" "por-hacer" "$(as leo $U/api/tareas | campo t-b estado)"
check "…t-c (sin campeón) sí pasó" "hecho" "$(as leo $U/api/tareas | campo t-c estado)"
echo "## lotes"
check "tablero.py acepta «campeon» en poner" "campeon" "$(grep -o '"campeon"' $REPO/backend/tablero.py | head -1)"
echo; echo "Resultado: $ok bien, $fail mal"; [ $fail -eq 0 ]

View File

@ -27,7 +27,12 @@
id → nombre). El pendiente exige responsable; se puede escribir texto libre si la persona aún no
está dada de alta (el servidor avisa). Kanban.nombresResp(t, equipo) sirve a otras páginas.
Estilos: tableros/tablero.css (todo bajo .tb). Sin emoji: íconos de línea (Kanban.icono) y
avatares con iniciales (Kanban.avatar), con el mismo color por persona en todo el sitio. */
avatares con iniciales (Kanban.avatar), con el mismo color por persona en todo el sitio.
Campeón (27-oct, Leo): cada pendiente tiene UNA persona que lo mueve entre columnas (campo «campeon», uno de los
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. */
(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']];
@ -136,9 +141,16 @@
}
function avatares(t) {
const g = document.createElement('span'); g.className = 'avs';
idsResp(t).forEach(id => g.append(avatar(id)));
const ids = idsResp(t);
// con varios responsables, el campeón va primero y con anillo
const c = ids.length > 1 ? ((t.campeon && ids.includes(t.campeon)) ? t.campeon : ids[0]) : '';
(c ? [c].concat(ids.filter(x => x !== c)) : ids).forEach(id => {
const a = avatar(id);
if (id === c) { a.classList.add('campeon'); a.title = 'Campeón: ' + a.title; }
g.append(a);
});
libresResp(t).forEach(x => g.append(avatar(null, x)));
g.title = nombresResp(t).join(', ');
g.title = (c ? 'Campeón: ' + ((EQUIPO[c] || {}).nombre || c) + ' · ' : '') + nombresResp(t).join(', ');
return g;
}
@ -165,7 +177,15 @@
function montar(root, opts) {
opts = opts || {};
let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = [];
let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = [], muevenSiempre = null;
// campeón (27-oct): quién mueve cada pendiente entre columnas
const campeonDe = t => { const ids = idsResp(t); return (t.campeon && ids.includes(t.campeon)) ? t.campeon : (ids[0] || ''); };
function puedeMover(t) {
if (!muevenSiempre) return true; // servidor de antes del 27-oct: cualquiera mueve
const u = norm(user), c = campeonDe(t);
return !c || (!!u && (u === c || admin || muevenSiempre.includes(u)));
}
const quienMueve = t => { const c = campeonDe(t); return (EQUIPO[c] || {}).nombre || c; };
const q0 = new URLSearchParams(location.search);
const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes', mios: q0.get('mios') === '1',
sitios: new Set((q0.get('sitio') || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean).map(x => ALIAS_SITIO[x] || x)) };
@ -268,6 +288,20 @@
cols[k] = c; kb.append(c);
});
root.append(kb);
// franja para soltar (27-oct): al arrastrar aparece fija arriba de la pantalla con las cuatro columnas, así no hay
// que arrastrar y bajar a la vez cuando la columna de destino quedó lejos
const dock = el('div', { class: 'kb-dock', hidden: '' });
ESTADOS.forEach(([k, n]) => {
const z = el('div', { class: 'kb-dock-z e-' + k, 'data-estado': k, html: ESTADO_SVG[k] + '<span>' + n + '</span>' });
z.addEventListener('dragover', e => { e.preventDefault(); z.classList.add('sobre'); });
z.addEventListener('dragleave', () => z.classList.remove('sobre'));
z.addEventListener('drop', e => {
e.preventDefault(); z.classList.remove('sobre'); dock.hidden = true;
const id = e.dataTransfer.getData('text/plain'); if (id) mover(id, k, 0);
});
dock.append(z);
});
root.append(dock);
const per = el('div', { class: 'kb-personas', hidden: '' });
root.append(per);
@ -298,9 +332,16 @@
const corto = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s;
function tarjeta(t) {
const c = el('div', { class: 'card p-' + t.pista + ' e-' + t.estado + (t.vencida ? ' vencida' : ''), draggable: 'true', 'data-id': t.id });
c.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', t.id); e.dataTransfer.effectAllowed = 'move'; c.classList.add('arrastrando'); });
c.addEventListener('dragend', () => c.classList.remove('arrastrando'));
const mueve = puedeMover(t);
const c = el('div', { class: 'card p-' + t.pista + ' e-' + t.estado + (t.vencida ? ' vencida' : '') + (mueve ? '' : ' fija'), draggable: mueve ? 'true' : 'false', 'data-id': t.id });
if (mueve) {
c.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', t.id); e.dataTransfer.effectAllowed = 'move'; c.classList.add('arrastrando');
// la franja se muestra un instante después: cambiar la página en el mismo dragstart cancela el arrastre en Chrome
setTimeout(() => { dock.dataset.desde = t.estado; dock.hidden = false; }, 0);
});
c.addEventListener('dragend', () => { c.classList.remove('arrastrando'); dock.hidden = true; dock.querySelectorAll('.sobre').forEach(z => z.classList.remove('sobre')); });
}
// pista y versión de tarjeta
const sup = el('div', { class: 'sup' });
if (!opts.pista) sup.append(el('span', { class: 'pista-p' }), el('span', { text: PISTAS[t.pista] || t.pista }));
@ -328,16 +369,37 @@
// responsables y fecha
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 f = fechaDe(t);
if (f) pie.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)
if (mueve) {
const i = ESTADOS.findIndex(e => e[0] === t.estado);
const fl = el('span', { class: 'flechas' });
[[-1, 'izq'], [1, 'der']].forEach(([d, cls]) => {
const dest = ESTADOS[i + d];
const b = el('button', { type: 'button', class: 'fl ' + cls, html: icono('flecha') });
if (dest) {
b.title = 'Mover a ' + dest[1]; b.setAttribute('aria-label', 'Mover a ' + dest[1]);
b.addEventListener('click', ev => { ev.stopPropagation(); mover(t.id, dest[0], 0); });
} else { b.disabled = true; b.setAttribute('aria-hidden', 'true'); b.tabIndex = -1; }
fl.append(b);
});
der.append(fl);
} else {
der.append(el('span', { class: 'candado', title: 'Lo mueve ' + quienMueve(t) + ' (campeón) o un administrador', html: icono('persona') }));
}
pie.append(der);
c.append(pie);
// "Mover a…": teléfonos y quien no arrastra
const mv = el('div', { class: 'mover' });
const sel = el('select', { 'aria-label': 'Mover a otra columna' });
sel.append(el('option', { value: '', text: 'Mover a…' }));
ESTADOS.filter(([k]) => k !== t.estado).forEach(([k, n]) => sel.append(el('option', { value: k, text: n })));
sel.addEventListener('change', () => { if (sel.value) mover(t.id, sel.value, 0); });
mv.append(sel); c.append(mv);
if (mueve) {
const mv = el('div', { class: 'mover' });
const sel = el('select', { 'aria-label': 'Mover a otra columna' });
sel.append(el('option', { value: '', text: 'Mover a…' }));
ESTADOS.filter(([k]) => k !== t.estado).forEach(([k, n]) => sel.append(el('option', { value: k, text: n })));
sel.addEventListener('change', () => { if (sel.value) mover(t.id, sel.value, 0); });
mv.append(sel); c.append(mv);
}
return c;
}
@ -498,6 +560,7 @@
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
puedeBorrar = j.puede_borrar === undefined ? admin : !!j.puede_borrar; // servidor de antes del 22-oct: solo admin
borran = Array.isArray(j.borran) ? j.borran : [];
muevenSiempre = Array.isArray(j.mueven_siempre) ? j.mueven_siempre.map(norm) : null; // null: servidor de antes del 27-oct
if (Array.isArray(j.sitios) && j.sitios.length) SITIOS = j.sitios.map(x => [x.id, x.corto || x.id, x.id === 'qro' ? 'Querétaro' : (x.corto || x.id), x.nombre || x.id]);
usarEquipo(j.equipo);
pintar();
@ -508,6 +571,10 @@
// ---------- mover (arrastre o "Mover a…") ----------
async function mover(id, estado, idx) {
const t = porId[id]; if (!t) return;
if (t.estado !== estado && !puedeMover(t)) {
mensaje('Solo ' + quienMueve(t) + ', campeón de «' + corto(t.titulo, 41) + '», o un administrador lo cambia de columna.', false);
return;
}
const mismaCol = t.estado === estado;
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);
@ -549,11 +616,24 @@
Object.entries(EQUIPO).filter(([id, m]) => m.activo !== false || yaIds.includes(id)).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre)).forEach(([id, m]) => {
const l = el('label', { class: 'chip-sel' + (yaIds.includes(id) ? ' on' : '') });
const ck = el('input', { type: 'checkbox', value: id }); ck.checked = yaIds.includes(id);
ck.addEventListener('change', () => l.classList.toggle('on', ck.checked));
ck.addEventListener('change', () => { l.classList.toggle('on', ck.checked); pintarCamp(); });
l.append(ck, avatar(id), document.createTextNode(m.nombre + (m.activo === false ? ' (inactivo)' : ''))); fResp.append(l);
});
const fLibre = el('input', { type: 'text', value: libresResp(t).join(', '), placeholder: 'Otro (texto libre, solo si la persona aún no está en Equipo)', style: 'margin-top:8px' });
const leerResp = () => [...fResp.querySelectorAll('input:checked')].map(c => c.value).concat(fLibre.value.split(',').map(x => x.trim()).filter(Boolean)).join(', ');
// campeón (27-oct): uno de los responsables marcados; es quien mueve el pendiente entre columnas
const pm = nuevo || puedeMover(t);
const fCamp = el('select', { 'aria-label': 'Campeón' });
function pintarCamp() {
const marcados = [...fResp.querySelectorAll('input:checked')].map(c => c.value);
const antes = fCamp.value || (nuevo ? '' : campeonDe(t));
fCamp.innerHTML = '';
if (!marcados.length) fCamp.append(el('option', { value: '', text: 'Primero marca a los responsables' }));
marcados.forEach(id => fCamp.append(el('option', { value: id, text: (EQUIPO[id] || {}).nombre || id })));
fCamp.value = marcados.includes(antes) ? antes : (marcados[0] || '');
}
pintarCamp();
fCamp.disabled = !pm;
const fFecha = el('input', { type: 'date', value: t.fecha_limite || '' });
const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? 'No aplica' : v }))); fVer.value = t.version_tarjeta || '—';
const fEtq = el('input', { type: 'text', value: (t.etiquetas || []).join(', '), placeholder: 'layout, crisis, quick-win' });
@ -567,10 +647,27 @@
l.append(ck, el('span', { class: 'kb-s-pin', html: icono('sitio') }), document.createTextNode(nombre)); fSit.append(l);
});
// columna (27-oct): botones que mueven el pendiente de inmediato (solo el campeón o un administrador)
if (!nuevo) {
const mvD = el('div', { class: 'd-mover', role: 'group', 'aria-label': 'Columna' });
ESTADOS.forEach(([k, n]) => {
const b = el('button', { type: 'button', 'aria-pressed': String(t.estado === k), html: ESTADO_SVG[k] + '<span>' + n + '</span>' });
b.disabled = !pm && t.estado !== k;
b.addEventListener('click', () => { if (t.estado === k) return; dlg.close(); mover(t.id, k, 0); });
mvD.append(b);
});
const cMv = campo('Columna', mvD);
cMv.append(el('div', { class: 'd-nota', text: pm ? 'Se mueve al hacer clic (lo demás se guarda con «Guardar cambios»).'
: 'Solo ' + quienMueve(t) + ', campeón de este pendiente, o un administrador lo cambia de columna o de campeón.' }));
cuerpo.append(cMv);
}
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista));
if (nuevo) g1.append(campo('Estado', fEst));
g1.append(campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1);
cuerpo.append(campo('Sitio (dónde pasa; puede ser más de uno)', fSit));
const cResp = campo('Responsable (obligatorio; puede ser más de uno)', fResp); cResp.append(fLibre); cuerpo.append(cResp);
cuerpo.append(campo('Campeón (uno solo: mueve el pendiente entre columnas y responde por él)', fCamp));
// enlaces
const enlBox = el('div', { class: 'enlaces-ed' });
@ -621,7 +718,7 @@
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: nuevo ? 'Crear pendiente' : 'Guardar cambios' });
guardar.addEventListener('click', async () => {
const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: fEst.value, responsable: leerResp(),
const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: nuevo ? fEst.value : t.estado, responsable: leerResp(), campeon: fCamp.value,
fecha_limite: fFecha.value || null, version_tarjeta: fVer.value, etiquetas: fEtq.value.split(',').map(s => s.trim()).filter(Boolean),
enlaces: [...enlBox.querySelectorAll('.fila')].map(f => ({ titulo: f.children[0].value.trim(), url: f.children[1].value.trim() })).filter(e => e.url),
bloqueada_por: [...lst.querySelectorAll('input:checked')].map(c => c.value),
@ -638,6 +735,8 @@
if (k === 'sitios') { if ([...d.sitios].sort().join() !== [...sitiosDe(t)].sort().join()) cambios.sitios = d.sitios; return; }
if (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k];
});
if (cambios.campeon === '') delete cambios.campeon; // solo responsables en texto libre: el servidor decide
if (cambios.responsable && d.campeon) cambios.campeon = d.campeon; // que reordenar responsables no cambie al campeón
if (Object.keys(cambios).length) r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
}
dlg.close(); mensaje((nuevo ? 'Pendiente creado.' : 'Cambios guardados.') + (r && r.aviso ? ' Aviso: ' + r.aviso : ''), !(r && r.aviso)); recargar();

View File

@ -16,7 +16,8 @@ hide:
<summary>Cómo se usa</summary>
<ol>
<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>Arrastra el pendiente a la columna que corresponda; en el teléfono usa <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>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>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>

View File

@ -342,6 +342,51 @@
@media (min-width: 981px) { .tb .card .mover { display: none; } .tb .kb.movil .card .mover { display: flex; } }
.tb .kb.compacto .card .mover { display: none; } /* el extracto del inicio: se mueve desde el detalle o el tablero completo */
/* campeón (27-oct): con varios responsables, el avatar de quien mueve el pendiente va primero, con anillo */
.tb .av.campeon { position: relative; z-index: 1; box-shadow: 0 0 0 2px var(--hg-plano), 0 0 0 3.5px var(--hg-acento); }
.tb .card.fija { cursor: default; }
.tb .card .pie .der { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
.tb .card .candado { display: inline-grid; place-items: center; color: var(--hg-texto-3); }
.tb .card .flechas { display: inline-flex; gap: 3px; }
.tb .card .flechas .fl {
display: inline-grid; place-items: center; width: 26px; height: 24px; padding: 0;
border-radius: 6px; border: 1px solid var(--hg-linea); background: transparent; color: var(--hg-texto-2);
}
.tb .card .flechas .fl:hover:not(:disabled) { color: var(--hg-texto-fuerte); border-color: var(--hg-linea-2); filter: none; }
.tb .card .flechas .fl:disabled { visibility: hidden; }
.tb .card .flechas .fl.izq .i { transform: scaleX(-1); }
/* franja para soltar (27-oct): fija arriba de la pantalla mientras se arrastra un pendiente */
.tb .kb-dock {
position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 60;
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px;
width: min(760px, calc(100vw - 32px)); padding: 8px; border-radius: 12px;
background: var(--hg-plano); border: 1px solid var(--hg-linea-2); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.tb .kb-dock[hidden] { display: none; }
.tb .kb-dock-z {
display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 54px; padding: 8px;
border-radius: 8px; border: 1.5px dashed var(--hg-linea-2); font-size: 14px; font-weight: 600; color: var(--hg-texto);
}
.tb .kb-dock-z svg { width: 16px; height: 16px; flex: none; }
.tb .kb-dock-z > * { pointer-events: none; }
.tb .kb-dock-z.sobre { border-style: solid; border-color: var(--hg-acento); background: color-mix(in srgb, var(--hg-acento) 12%, transparent); }
.tb .kb-dock[data-desde="por-hacer"] .e-por-hacer, .tb .kb-dock[data-desde="en-curso"] .e-en-curso,
.tb .kb-dock[data-desde="bloqueado"] .e-bloqueado, .tb .kb-dock[data-desde="hecho"] .e-hecho { opacity: 0.45; }
@media (max-width: 600px) { .tb .kb-dock { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* detalle: botones de columna */
.tb .d-mover { display: flex; flex-wrap: wrap; gap: 6px; }
.tb .d-mover button {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border-radius: 99px;
border: 1px solid var(--hg-linea-2); background: transparent; color: var(--hg-texto); font-size: 13.5px; font-weight: 500;
}
.tb .d-mover button svg { width: 14px; height: 14px; flex: none; }
.tb .d-mover button:hover:not(:disabled) { filter: none; border-color: var(--hg-texto-3); }
.tb .d-mover button[aria-pressed="true"] { background: var(--hg-curso-bg); border-color: color-mix(in srgb, var(--hg-curso) 45%, transparent); color: var(--hg-curso); }
.tb .d-mover button:disabled { opacity: 0.45; }
.tb .d-nota { margin-top: 6px; font-size: 12.5px; color: var(--hg-texto-3); }
/* ---- Diálogo (detalle y alta) -------------------------------------------- */
.tb dialog {
border: 1px solid var(--hg-linea-2);