fix(admin): bug del activo + simplificar a Nombre Completo + gear en landing

BUG FIX
* admin.html: el filtro <select id="f-activo"> y el modal <input id="f-activo">
  tenían el MISMO id. document.getElementById devolvía el select (primer en
  DOM order) y la lectura .checked siempre era undefined → JSON enviaba el
  valor stale del select. Renombré el filtro a `f-activo-filter`.

REFACTOR: drop apellido, usar Nombre Completo
* Migración 007: copia apellido→name para placeholders, migra
  escrituras_tramites.abogado y tramites_legales.abogado a usar nombre
  completo, DROP COLUMN apellido + índice.
* Lookups: abogadoApellidos() → abogadoNames() filtra socios+abogados
  activos con name no vacío.
* Auth.php: drop método apellido(), agregado userName().
* tramites/api.php: visibilidad usa users.name = t.abogado.
* admin/api.php: drop apellido en create/update payloads.
* admin.html: drop columna y campo Apellido del table y form.
* Bootstrap envía window.DONNA.abogados con nombres completos.

LANDING (index.php)
* Gear icon en dash-header visible solo si Auth::isSocio() (chequeo
  server-side, sin flash de JS).

CLEANUP
* Eliminado logging de debug en admin/api.php que se agregó para
  diagnosticar el bug del activo.

PERMISOS (sin cambios — confirmación)
* Trámites Legales: solo socio/abogado pueden crear (canCreate() ya lo
  enforza desde 2db8f23).
* Escrituras: cualquier rol autenticado puede crear (escrituras/api.php
  nunca tuvo restricción).
This commit is contained in:
Soporte ZH Consultores
2026-06-04 23:42:06 +00:00
parent 2db8f23ba5
commit 072cc0b6e3
8 changed files with 83 additions and 58 deletions
+13 -15
View File
@@ -41,27 +41,26 @@ try {
if ($email === '') err('email requerido');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) err('email inválido');
$name = trim((string)($b['name'] ?? ''));
$role = in_array(($b['role'] ?? ''), ROLES, true) ? $b['role'] : 'usuario';
$apellido = trim((string)($b['apellido'] ?? ''));
$activo = (int)($b['activo'] ?? 1) === 1 ? 1 : 0;
$orden = (int)($b['orden'] ?? 0);
$name = trim((string)($b['name'] ?? ''));
$role = in_array(($b['role'] ?? ''), ROLES, true) ? $b['role'] : 'usuario';
$activo = (int)($b['activo'] ?? 1) === 1 ? 1 : 0;
$orden = (int)($b['orden'] ?? 0);
$exists = $pdo->prepare('SELECT id FROM users WHERE email = :e');
$exists->execute([':e' => $email]);
if ($exists->fetch()) err('Ya existe un usuario con ese email', 409);
$ins = $pdo->prepare(
'INSERT INTO users (email, name, role, apellido, activo, orden, first_seen_at, last_login_at)
VALUES (:email, :name, :role, :apellido, :activo, :orden, NULL, NULL)'
'INSERT INTO users (email, name, role, activo, orden, first_seen_at, last_login_at)
VALUES (:email, :name, :role, :activo, :orden, NULL, NULL)'
);
$ins->execute([
':email' => $email, ':name' => $name, ':role' => $role,
':apellido' => $apellido, ':activo' => $activo, ':orden' => $orden,
':activo' => $activo, ':orden' => $orden,
]);
$id = (int)$pdo->lastInsertId();
$sel = $pdo->prepare('SELECT id, email, name, role, apellido, activo, orden, first_seen_at, last_login_at FROM users WHERE id = :id');
$sel = $pdo->prepare('SELECT id, email, name, role, activo, orden, first_seen_at, last_login_at FROM users WHERE id = :id');
$sel->execute([':id' => $id]);
$row = $sel->fetch();
$row['id'] = (int)$row['id'];
@@ -97,22 +96,21 @@ try {
if ($chk->fetch()) err('Otro usuario ya tiene ese email', 409);
}
$name = trim((string)($b['name'] ?? ''));
$apellido = trim((string)($b['apellido'] ?? ''));
$orden = (int)($b['orden'] ?? 0);
$name = trim((string)($b['name'] ?? ''));
$orden = (int)($b['orden'] ?? 0);
$upd = $pdo->prepare(
'UPDATE users SET email = :email, name = :name, role = :role,
apellido = :apellido, activo = :activo, orden = :orden
activo = :activo, orden = :orden
WHERE id = :id'
);
$upd->execute([
':email' => $newEmail, ':name' => $name, ':role' => $newRole,
':apellido' => $apellido, ':activo' => $newActivo, ':orden' => $orden,
':activo' => $newActivo, ':orden' => $orden,
':id' => $id,
]);
$sel = $pdo->prepare('SELECT id, email, name, role, apellido, activo, orden, first_seen_at, last_login_at FROM users WHERE id = :id');
$sel = $pdo->prepare('SELECT id, email, name, role, activo, orden, first_seen_at, last_login_at FROM users WHERE id = :id');
$sel->execute([':id' => $id]);
$row = $sel->fetch();
$row['id'] = (int)$row['id'];
+16 -6
View File
@@ -106,6 +106,9 @@ a{color:inherit;text-decoration:none;}
.user-email{font-size:10.5px;color:rgba(255,255,255,0.55);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.btn-logout{font-size:11px;padding:6px 12px;border:1px solid rgba(255,255,255,0.18);border-radius:18px;background:transparent;color:rgba(255,255,255,0.7);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;}
.btn-logout:hover{background:rgba(255,255,255,0.12);color:#fff;border-color:rgba(255,255,255,0.3);}
.btn-gear{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border:1px solid rgba(255,255,255,0.18);border-radius:8px;color:rgba(255,255,255,0.85);transition:all .15s;text-decoration:none;font-family:'DM Sans',sans-serif;margin-right:8px;}
.btn-gear:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.btn-gear svg{width:16px;height:16px;}
.dash-main{max-width:1180px;margin:0 auto;padding:48px 32px 80px;}
.dash-hero{margin-bottom:40px;}
@@ -199,13 +202,20 @@ a{color:inherit;text-decoration:none;}
<span class="brand-name" style="font-size:14px;color:rgba(255,255,255,0.7);">Portal interno</span>
</div>
</div>
<div class="user-chip">
<div class="user-avatar"><?= htmlspecialchars(initials($user['name'] ?: $user['email'])) ?></div>
<div class="user-info">
<span class="user-name"><?= htmlspecialchars($user['name'] ?: $user['email']) ?></span>
<?php if ($user['name']): ?><span class="user-email"><?= htmlspecialchars($user['email']) ?></span><?php endif; ?>
<div style="display:flex;align-items:center;">
<?php if (Auth::isSocio()): ?>
<a class="btn-gear" href="/admin/" title="Panel de Control">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a>
<?php endif; ?>
<div class="user-chip">
<div class="user-avatar"><?= htmlspecialchars(initials($user['name'] ?: $user['email'])) ?></div>
<div class="user-info">
<span class="user-name"><?= htmlspecialchars($user['name'] ?: $user['email']) ?></span>
<?php if ($user['name']): ?><span class="user-email"><?= htmlspecialchars($user['email']) ?></span><?php endif; ?>
</div>
<a href="/auth/logout.php" class="btn-logout">Cerrar sesión</a>
</div>
<a href="/auth/logout.php" class="btn-logout">Cerrar sesión</a>
</div>
</header>
+4 -4
View File
@@ -91,7 +91,7 @@ function hydrate(PDO $pdo, array $row): array {
}, $hrs->fetchAll());
$inv = $pdo->prepare(
'SELECT u.id, u.email, u.name, u.apellido
'SELECT u.id, u.email, u.name
FROM tramites_involucrados ti
JOIN users u ON u.id = ti.user_id
WHERE ti.tramite_id = :t
@@ -109,7 +109,7 @@ function hydrate(PDO $pdo, array $row): array {
/**
* ¿El usuario actual puede ver/editar este trámite?
* Socio → siempre
* Resto → si es creador, o abogado solicitante (apellido), o involucrado
* Resto → si es creador, o abogado solicitante (name), o involucrado
*/
function canView(PDO $pdo, int $tid): bool {
if (Auth::isSocio()) return true;
@@ -117,7 +117,7 @@ function canView(PDO $pdo, int $tid): bool {
$uid = Auth::userId();
$stmt = $pdo->prepare(
"SELECT 1 FROM tramites_legales t
LEFT JOIN users ua ON ua.apellido = t.abogado AND ua.activo = 1
LEFT JOIN users ua ON ua.name = t.abogado AND ua.activo = 1
LEFT JOIN tramites_involucrados ti ON ti.tramite_id = t.id AND ti.user_id = :uid
WHERE t.id = :tid
AND (t.creado_por = :email OR ua.id = :uid2 OR ti.user_id IS NOT NULL)
@@ -153,7 +153,7 @@ try {
} else {
$stmt = $pdo->prepare(
"SELECT DISTINCT t.* FROM tramites_legales t
LEFT JOIN users ua ON ua.apellido = t.abogado AND ua.activo = 1
LEFT JOIN users ua ON ua.name = t.abogado AND ua.activo = 1
LEFT JOIN tramites_involucrados ti ON ti.tramite_id = t.id AND ti.user_id = :uid
WHERE t.creado_por = :email
OR ua.id = :uid2
+26
View File
@@ -0,0 +1,26 @@
-- ============================================================
-- Donna — Schema 007: Eliminar apellido, usar Nombre Completo
-- ============================================================
-- Decisión: simplificar el modelo trabajando solo con `name` en lugar de
-- mantener apellido + name. El dropdown "Abogado Solicitante" muestra el
-- nombre completo en lugar del apellido suelto.
-- 1) Migrar nombres de placeholders desde apellido
UPDATE users
SET name = apellido
WHERE (name = '' OR name = '(Asignar email real)')
AND apellido != '';
-- 2) Migrar tramites a usar el name en lugar del apellido
UPDATE escrituras_tramites t
JOIN users u ON u.apellido = t.abogado AND u.apellido != '' AND u.name != ''
SET t.abogado = u.name;
UPDATE tramites_legales t
JOIN users u ON u.apellido = t.abogado AND u.apellido != '' AND u.name != ''
SET t.abogado = u.name;
-- 3) Drop columna apellido + su índice
ALTER TABLE users
DROP INDEX idx_apellido,
DROP COLUMN apellido;
+3 -3
View File
@@ -47,7 +47,7 @@ final class Auth {
if (!$email) return self::$userRowCache = self::defaultRow();
try {
$stmt = Db::pdo()->prepare(
'SELECT id, email, name, role, apellido, activo
'SELECT id, email, name, role, activo
FROM users WHERE email = :e LIMIT 1'
);
$stmt->execute([':e' => $email]);
@@ -61,12 +61,12 @@ final class Auth {
}
private static function defaultRow(): array {
return ['id' => 0, 'email' => '', 'name' => '', 'role' => 'usuario', 'apellido' => '', 'activo' => 1];
return ['id' => 0, 'email' => '', 'name' => '', 'role' => 'usuario', 'activo' => 1];
}
public static function userId(): int { return (int)(self::userRow()['id'] ?? 0); }
public static function role(): string { return (string)(self::userRow()['role'] ?? 'usuario'); }
public static function apellido(): string { return (string)(self::userRow()['apellido'] ?? ''); }
public static function userName(): string { return (string)(self::userRow()['name'] ?? ''); }
public static function isSocio(): bool { return self::role() === 'socio'; }
public static function isAbogado(): bool { return in_array(self::role(), ['socio','abogado'], true); }
+1 -1
View File
@@ -20,7 +20,7 @@ final class Bootstrap {
'isAbogado' => Auth::isAbogado(),
'canCreate' => Auth::canCreate(),
'canDelete' => Auth::canDelete(),
'abogados' => Lookups::abogadoApellidos(),
'abogados' => Lookups::abogadoNames(),
'users' => Lookups::involucrablesUsers(),
];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP);
+10 -10
View File
@@ -5,25 +5,25 @@ require_once __DIR__ . '/Db.php';
/**
* Lookups — vistas de solo lectura sobre la tabla users para poblar
* dropdowns y resolver mapeo email ↔ apellido.
* dropdowns y resolver visibilidad.
*/
final class Lookups {
/** Apellidos para el dropdown "Abogado Solicitante" (socios + abogados activos). */
public static function abogadoApellidos(): array {
/** Nombres completos para el dropdown "Abogado Solicitante" (socios + abogados activos). */
public static function abogadoNames(): array {
$rows = Db::pdo()->query(
"SELECT DISTINCT apellido FROM users
"SELECT DISTINCT name FROM users
WHERE activo = 1
AND role IN ('socio','abogado')
AND apellido != ''
ORDER BY orden ASC, apellido ASC"
AND name != ''
ORDER BY orden ASC, name ASC"
)->fetchAll();
return array_map(fn($r) => $r['apellido'], $rows);
return array_map(fn($r) => $r['name'], $rows);
}
/** Todos los usuarios activos para el picker de Involucrados. */
public static function involucrablesUsers(): array {
$rows = Db::pdo()->query(
"SELECT id, email, name, role, apellido
"SELECT id, email, name, role
FROM users
WHERE activo = 1
AND email NOT LIKE 'placeholder-%'
@@ -36,10 +36,10 @@ final class Lookups {
/** Todos los users (incluye inactivos y placeholders) para el Panel de Control. */
public static function allUsers(): array {
$rows = Db::pdo()->query(
"SELECT id, email, name, role, apellido, activo, orden,
"SELECT id, email, name, role, activo, orden,
first_seen_at, last_login_at
FROM users
ORDER BY role DESC, apellido ASC, name ASC, email ASC"
ORDER BY role DESC, name ASC, email ASC"
)->fetchAll();
foreach ($rows as &$r) {
$r['id'] = (int)$r['id'];
+10 -19
View File
@@ -157,18 +157,18 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<div class="intro">
<h1>Usuarios y permisos</h1>
<p>Administra la lista unificada de usuarios del sistema. El campo <b>Apellido</b> es lo que aparece en el dropdown "Abogado Solicitante" (solo si el rol es <b>Socio</b> o <b>Abogado</b>). Los emails que empiezan con <code>placeholder-</code> son los apellidos sembrados al inicio para que los dropdowns no salieran vacíos; reemplázalos con los emails reales conforme los abogados se logueen.</p>
<p>Administra la lista unificada de usuarios del sistema. El campo <b>Nombre completo</b> es lo que aparece en el dropdown "Abogado Solicitante" (solo si el rol es <b>Socio</b> o <b>Abogado</b>). Los emails que empiezan con <code>placeholder-</code> son los nombres sembrados al inicio para que los dropdowns no salieran vacíos; reemplázalos con los emails reales conforme los abogados se logueen.</p>
</div>
<div class="toolbar">
<input type="search" id="search" placeholder="Buscar por email, nombre o apellido…">
<input type="search" id="search" placeholder="Buscar por email o nombre…">
<select id="f-role">
<option value="">— Todos los roles —</option>
<option value="socio">Socio</option>
<option value="abogado">Abogado</option>
<option value="usuario">Usuario</option>
</select>
<select id="f-activo">
<select id="f-activo-filter">
<option value="">— Activos e inactivos —</option>
<option value="1">Solo activos</option>
<option value="0">Solo inactivos</option>
@@ -182,8 +182,7 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<thead>
<tr>
<th>Email</th>
<th>Nombre</th>
<th>Apellido</th>
<th>Nombre completo</th>
<th>Rol</th>
<th>Activo</th>
<th>Orden</th>
@@ -209,15 +208,11 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<input type="email" id="f-email" placeholder="usuario@aal-law.com">
<div class="fg-hint" id="hint-email">El email debe coincidir exactamente con la cuenta de Mailcow que el usuario usa para entrar.</div>
</div>
<div class="fg">
<label>Nombre completo</label>
<input type="text" id="f-name" placeholder="Ej: Carlos Abrego">
</div>
<div class="fg-row">
<div class="fg">
<label>Apellido (dropdown)</label>
<input type="text" id="f-apellido" placeholder="Ej: Abrego">
<div class="fg-hint">Solo aparece en dropdowns si el rol es Socio o Abogado.</div>
<label>Nombre completo</label>
<input type="text" id="f-name" placeholder="Ej: Carlos Abrego">
<div class="fg-hint">Aparece en el dropdown "Abogado Solicitante" si el rol es Socio o Abogado.</div>
</div>
<div class="fg">
<label>Rol</label>
@@ -310,12 +305,12 @@ async function loadUsers(){
function visibleUsers(){
const q = $('search').value.toLowerCase().trim();
const fR = $('f-role').value;
const fA = $('f-activo').value;
const fA = $('f-activo-filter').value;
return users.filter(u => {
if (fR && u.role !== fR) return false;
if (fA !== '' && String(u.activo) !== fA) return false;
if (q) {
const blob = ((u.email||'')+' '+(u.name||'')+' '+(u.apellido||'')).toLowerCase();
const blob = ((u.email||'')+' '+(u.name||'')).toLowerCase();
if (!blob.includes(q)) return false;
}
return true;
@@ -338,7 +333,6 @@ function render(){
return `<tr class="${rowCls}">
<td><b>${esc(u.email)}</b>${isPlaceholder?' <span class="placeholder">(placeholder)</span>':''}${isMe?' <span style="color:var(--gold-d);font-size:10px;">(tú)</span>':''}</td>
<td>${esc(u.name) || '<span class="placeholder">—</span>'}</td>
<td>${esc(u.apellido) || '<span class="placeholder">—</span>'}</td>
<td><span class="role-pill role-${u.role}">${ROLE_LABEL[u.role] || u.role}</span></td>
<td><span class="activo-pill activo-${u.activo}">${u.activo ? 'Activo' : 'Inactivo'}</span></td>
<td>${u.orden}</td>
@@ -353,7 +347,6 @@ function openCreate(){
$('modal-title').textContent = 'Nuevo usuario';
$('f-email').value = ''; $('f-email').readOnly = false;
$('f-name').value = '';
$('f-apellido').value = '';
$('f-role-edit').value = 'usuario';
$('f-orden').value = '0';
$('f-activo').checked = true;
@@ -370,7 +363,6 @@ function openEdit(id){
$('f-email').value = u.email;
$('f-email').readOnly = false;
$('f-name').value = u.name || '';
$('f-apellido').value = u.apellido || '';
$('f-role-edit').value = u.role;
$('f-orden').value = u.orden;
$('f-activo').checked = u.activo === 1;
@@ -390,7 +382,6 @@ async function saveUser(){
const payload = {
email: $('f-email').value.trim().toLowerCase(),
name: $('f-name').value.trim(),
apellido: $('f-apellido').value.trim(),
role: $('f-role-edit').value,
orden: parseInt($('f-orden').value, 10) || 0,
activo: $('f-activo').checked ? 1 : 0,
@@ -423,7 +414,7 @@ async function saveUser(){
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
['search','f-role','f-activo'].forEach(id => {
['search','f-role','f-activo-filter'].forEach(id => {
$(id).addEventListener('input', render);
$(id).addEventListener('change', render);
});