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:
parent
158c9fdd13
commit
1e63a7c738
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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}$")
|
||||
|
||||
@ -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
60
backend/tests-campeon.sh
Normal 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 ]
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user