From 1e63a7c7383c7235fcb2aa46ff7fab4edbaaa1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leopoldo=20Rodr=C3=ADguez?= Date: Tue, 27 Oct 2026 21:15:48 -0600 Subject: [PATCH] =?UTF-8?q?Pendientes:=20campe=C3=B3n=20por=20pendiente,?= =?UTF-8?q?=20flechas=20=E2=86=90=20=E2=86=92,=20franja=20para=20soltar=20?= =?UTF-8?q?y=20columna=20en=20el=20detalle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013SfRSvyewSyFLfK9mjbGL8 --- backend/app.py | 53 +++++++++++++- backend/lotes/README.md | 2 +- backend/tablero.py | 2 +- backend/tests-1014.sh | 3 +- backend/tests-campeon.sh | 60 ++++++++++++++++ docs/tableros/kanban.js | 133 +++++++++++++++++++++++++++++++----- docs/tableros/pendientes.md | 3 +- docs/tableros/tablero.css | 45 ++++++++++++ 8 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 backend/tests-campeon.sh diff --git a/backend/app.py b/backend/app.py index 086fbab..6d20255 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/backend/lotes/README.md b/backend/lotes/README.md index eeb986a..d9de65c 100644 --- a/backend/lotes/README.md +++ b/backend/lotes/README.md @@ -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 | diff --git a/backend/tablero.py b/backend/tablero.py index d1c6e43..9475c1c 100644 --- a/backend/tablero.py +++ b/backend/tablero.py @@ -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}$") diff --git a/backend/tests-1014.sh b/backend/tests-1014.sh index 6ba307c..114de44 100644 --- a/backend/tests-1014.sh +++ b/backend/tests-1014.sh @@ -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=(",",":")))')" diff --git a/backend/tests-campeon.sh b/backend/tests-campeon.sh new file mode 100644 index 0000000..67b4c89 --- /dev/null +++ b/backend/tests-campeon.sh @@ -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 [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 ] diff --git a/docs/tableros/kanban.js b/docs/tableros/kanban.js index 57d8aea..0067fe4 100644 --- a/docs/tableros/kanban.js +++ b/docs/tableros/kanban.js @@ -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] + '' + n + '' }); + 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] + '' + n + '' }); + 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(); diff --git a/docs/tableros/pendientes.md b/docs/tableros/pendientes.md index ad0dadf..7657930 100644 --- a/docs/tableros/pendientes.md +++ b/docs/tableros/pendientes.md @@ -16,7 +16,8 @@ hide: Cómo se usa
  1. Cada tarjeta del tablero es un pendiente (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.
  2. -
  3. Arrastra el pendiente a la columna que corresponda; en el teléfono usa Mover a…. El tablero anota quién lo movió y cuándo.
  4. +
  5. 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.
  6. +
  7. 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.
  8. Toca el título para ver el detalle: descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda.
  9. Nuevo pendiente: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».
  10. Columna Bloqueado: solo si no puedes avanzar. Escribe en la descripción qué falta y quién lo destraba.
  11. diff --git a/docs/tableros/tablero.css b/docs/tableros/tablero.css index 78fd598..1cded56 100644 --- a/docs/tableros/tablero.css +++ b/docs/tableros/tablero.css @@ -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);