From 2db8f23ba5b01ba923a77c46e02b6e44de5b9415 Mon Sep 17 00:00:00 2001 From: Soporte ZH Consultores Date: Thu, 4 Jun 2026 23:14:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(perms):=20permisolog=C3=ADa=20completa=20c?= =?UTF-8?q?on=203=20roles=20(Socio/Abogado/Usuario)=20+=20Panel=20de=20Con?= =?UTF-8?q?trol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESQUEMA (migraciones 005 + 006) * users: agrega role ENUM('socio','abogado','usuario'), apellido, activo, orden * Seed: cabrego@aal-law.com como socio + 15 placeholders por apellido para poblar el dropdown "Abogado Solicitante" hasta que los reales se logueen. * tramites_involucrados: tabla N:M (tramite_id, user_id) con CASCADE delete. AUTH (src/Auth.php) * userRow() cachea el row del usuario actual por request. * role(), isSocio(), isAbogado(), canCreate(), canDelete(), requireSocio(). * completeLogin() retorna bool: false si user.activo=0 → callback muestra pantalla "cuenta deshabilitada" en lugar de loguear. BOOTSTRAP (src/Bootstrap.php + src/Lookups.php) * Bootstrap::inject(view) reemplaza readfile en los wrappers; inyecta antes de para que el frontend tenga contexto sin requests extra. TRAMITES — visibilidad + involucrados * api.php: list filtra por (socio | creador | abogado solicitante mapped via users.apellido | involucrado) excepto socios que ven todo. Todas las mutaciones validan canView(). create/delete validan rol. Persiste involucrados vía syncInvolucrados(). * tramites.html: nueva columna "Involucrados" después de "Abogado", chip picker multi-select en el modal, gear icon (oculto a non-socios), oculta botones "+ Nuevo" y ✕ para usuarios sin permisos. Auto-incluye al creador como involucrado por defecto. * Calendario y demás apps consumen el API filtrado → heredan la visibilidad sin código nuevo. PANEL DE CONTROL (/admin/) * index.php: requireSocio() + Bootstrap injection. * api.php: list_users, create_user, update_user. Salvaguardas: el socio no puede demoverse ni desactivarse a sí mismo. * admin.html: tabla unificada de usuarios con filtros, modal CRUD, resaltado de fila propia, placeholders identificados visualmente. GEAR ICON * Agregado en escrituras.html, tramites.html, calendario.html con display:none por defecto; JS lo revela si window.DONNA.isSocio. * Link a /admin/. --- public/admin/api.php | 132 +++++++++ public/admin/index.php | 8 + public/auth/callback.php | 4 +- public/calendario/index.php | 4 +- public/escrituras/index.php | 4 +- public/tramites/api.php | 91 ++++++- public/tramites/index.php | 4 +- sql/005_users_roles.sql | 52 ++++ sql/006_tramites_involucrados.sql | 19 ++ src/Auth.php | 112 +++++++- src/Bootstrap.php | 40 +++ src/Lookups.php | 59 ++++ views/admin.html | 434 ++++++++++++++++++++++++++++++ views/calendario.html | 16 ++ views/escrituras.html | 21 +- views/tramites.html | 125 +++++++-- 16 files changed, 1088 insertions(+), 37 deletions(-) create mode 100644 public/admin/api.php create mode 100644 public/admin/index.php create mode 100644 sql/005_users_roles.sql create mode 100644 sql/006_tramites_involucrados.sql create mode 100644 src/Bootstrap.php create mode 100644 src/Lookups.php create mode 100644 views/admin.html diff --git a/public/admin/api.php b/public/admin/api.php new file mode 100644 index 0000000..2331e23 --- /dev/null +++ b/public/admin/api.php @@ -0,0 +1,132 @@ + true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE); + exit; +} +function err(string $msg, int $code = 400): void { + http_response_code($code); + echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); + exit; +} +function body(): array { + return json_decode((string) file_get_contents('php://input'), true) ?? []; +} + +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? ''; +$me = Auth::userId(); +$pdo = Db::pdo(); + +try { + if ($method === 'GET' && $action === 'list_users') { + ok(Lookups::allUsers()); + } + + if ($method === 'POST' && $action === 'create_user') { + $b = body(); + $email = strtolower(trim((string)($b['email'] ?? ''))); + 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); + + $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)' + ); + $ins->execute([ + ':email' => $email, ':name' => $name, ':role' => $role, + ':apellido' => $apellido, ':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->execute([':id' => $id]); + $row = $sel->fetch(); + $row['id'] = (int)$row['id']; + $row['activo'] = (int)$row['activo']; + $row['orden'] = (int)$row['orden']; + ok($row); + } + + if ($method === 'PUT' && $action === 'update_user') { + $b = body(); + $id = (int)($b['_id'] ?? 0); + if (!$id) err('id requerido'); + + $sel = $pdo->prepare('SELECT id, email, role, activo FROM users WHERE id = :id'); + $sel->execute([':id' => $id]); + $existing = $sel->fetch(); + if (!$existing) err('Usuario no encontrado', 404); + + $newRole = in_array(($b['role'] ?? $existing['role']), ROLES, true) ? $b['role'] : $existing['role']; + $newActivo = isset($b['activo']) ? ((int)$b['activo'] === 1 ? 1 : 0) : (int)$existing['activo']; + + // Salvaguardas: el socio no puede demoverse ni desactivarse a sí mismo + if ((int)$existing['id'] === $me) { + if ($newRole !== 'socio') err('No puedes quitarte el rol de Socio a ti mismo', 400); + if ($newActivo === 0) err('No puedes desactivarte a ti mismo', 400); + } + + $newEmail = isset($b['email']) ? strtolower(trim((string)$b['email'])) : $existing['email']; + if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) err('email inválido'); + if ($newEmail !== $existing['email']) { + $chk = $pdo->prepare('SELECT id FROM users WHERE email = :e AND id != :id'); + $chk->execute([':e' => $newEmail, ':id' => $id]); + 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); + + $upd = $pdo->prepare( + 'UPDATE users SET email = :email, name = :name, role = :role, + apellido = :apellido, activo = :activo, orden = :orden + WHERE id = :id' + ); + $upd->execute([ + ':email' => $newEmail, ':name' => $name, ':role' => $newRole, + ':apellido' => $apellido, ':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->execute([':id' => $id]); + $row = $sel->fetch(); + $row['id'] = (int)$row['id']; + $row['activo'] = (int)$row['activo']; + $row['orden'] = (int)$row['orden']; + ok($row); + } + + err('Acción no reconocida', 404); + +} catch (PDOException $e) { + error_log('admin db error: ' . $e->getMessage()); + err('Error de base de datos', 500); +} catch (Throwable $e) { + error_log('admin error: ' . $e->getMessage()); + err('Error interno', 500); +} diff --git a/public/admin/index.php b/public/admin/index.php new file mode 100644 index 0000000..98393f9 --- /dev/null +++ b/public/admin/index.php @@ -0,0 +1,8 @@ +prepare('SELECT id, texto, abogado, autor_email, fecha FROM tramites_obs WHERE tramite_id = :t ORDER BY fecha ASC'); $obs->execute([':t' => $tid]); $row['obs'] = $obs->fetchAll(); @@ -89,17 +90,79 @@ function hydrate(PDO $pdo, array $row): array { return $h; }, $hrs->fetchAll()); + $inv = $pdo->prepare( + 'SELECT u.id, u.email, u.name, u.apellido + FROM tramites_involucrados ti + JOIN users u ON u.id = ti.user_id + WHERE ti.tramite_id = :t + ORDER BY u.name ASC, u.email ASC' + ); + $inv->execute([':t' => $tid]); + $row['involucrados'] = array_map(function ($u) { + $u['id'] = (int)$u['id']; + return $u; + }, $inv->fetchAll()); + return $row; } +/** + * ¿El usuario actual puede ver/editar este trámite? + * Socio → siempre + * Resto → si es creador, o abogado solicitante (apellido), o involucrado + */ +function canView(PDO $pdo, int $tid): bool { + if (Auth::isSocio()) return true; + $email = Auth::user()['email']; + $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 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) + LIMIT 1" + ); + $stmt->execute([':tid' => $tid, ':email' => $email, ':uid' => $uid, ':uid2' => $uid]); + return $stmt->fetchColumn() !== false; +} + +/** Reemplaza la lista de involucrados del trámite por la lista dada (array de user_id). */ +function syncInvolucrados(PDO $pdo, int $tid, array $userIds, string $addedBy): void { + $pdo->prepare('DELETE FROM tramites_involucrados WHERE tramite_id = :t')->execute([':t' => $tid]); + if (empty($userIds)) return; + $placeholders = implode(',', array_fill(0, count($userIds), '?')); + $check = $pdo->prepare("SELECT id FROM users WHERE activo = 1 AND id IN ($placeholders)"); + $check->execute(array_map('intval', $userIds)); + $valid = array_map('intval', $check->fetchAll(PDO::FETCH_COLUMN)); + if (!$valid) return; + $ins = $pdo->prepare('INSERT INTO tramites_involucrados (tramite_id, user_id, added_by) VALUES (?,?,?)'); + foreach ($valid as $uid) $ins->execute([$tid, $uid, $addedBy]); +} + $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; $user = Auth::user(); +$myId = Auth::userId(); $pdo = Db::pdo(); try { if ($method === 'GET' && $action === 'list') { - $rows = $pdo->query('SELECT * FROM tramites_legales ORDER BY id DESC')->fetchAll(); + if (Auth::isSocio()) { + $rows = $pdo->query('SELECT * FROM tramites_legales ORDER BY id DESC')->fetchAll(); + } 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 tramites_involucrados ti ON ti.tramite_id = t.id AND ti.user_id = :uid + WHERE t.creado_por = :email + OR ua.id = :uid2 + OR ti.user_id IS NOT NULL + ORDER BY t.id DESC" + ); + $stmt->execute([':email' => $user['email'], ':uid' => $myId, ':uid2' => $myId]); + $rows = $stmt->fetchAll(); + } $grouped = ['adm' => [], 'corp' => [], 'mig' => [], 'lit' => []]; foreach ($rows as $r) { $grouped[$r['departamento']][] = hydrate($pdo, $r); @@ -108,6 +171,8 @@ try { } if ($method === 'POST' && $action === 'create') { + if (!Auth::canCreate()) err('Tu rol no permite crear trámites. Solo Socios y Abogados pueden.', 403); + $b = body(); $dept = dept_of($b); @@ -156,6 +221,12 @@ try { ':user' => $user['email'], ]); $id = (int) $pdo->lastInsertId(); + + // Involucrados: si vienen del payload, se respetan; si no, se auto-incluye al creador + $involIds = array_map('intval', (array)($b['involucrados'] ?? [])); + if ($myId > 0 && !in_array($myId, $involIds, true)) $involIds[] = $myId; + syncInvolucrados($pdo, $id, $involIds, $user['email']); + $row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $row->execute([':id' => $id]); ok(hydrate($pdo, $row->fetch())); @@ -166,6 +237,8 @@ try { $id = (int) ($b['_id'] ?? 0); if (!$id) err('id requerido'); + if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); + $cur = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $cur->execute([':id' => $id]); $before = $cur->fetch(); @@ -212,6 +285,11 @@ try { ':user' => $user['email'], ':id' => $id, ]); + + if (array_key_exists('involucrados', $b) && is_array($b['involucrados'])) { + syncInvolucrados($pdo, $id, array_map('intval', $b['involucrados']), $user['email']); + } + $row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $row->execute([':id' => $id]); ok(hydrate($pdo, $row->fetch())); @@ -223,6 +301,8 @@ try { $val = (string) ($b['estatus'] ?? ''); if (!$id) err('id requerido'); + if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); + $cur = $pdo->prepare('SELECT departamento FROM tramites_legales WHERE id = :id'); $cur->execute([':id' => $id]); $dept = $cur->fetchColumn(); @@ -242,6 +322,8 @@ try { if (!$id) err('id requerido'); if ($texto === '') err('texto requerido'); + if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); + $pdo->prepare('INSERT INTO tramites_obs (tramite_id, texto, abogado, autor_email) VALUES (?,?,?,?)') ->execute([$id, $texto, $abog, $user['email']]); @@ -262,6 +344,8 @@ try { if (!$id) err('id requerido'); if ($hrs <= 0) err('horas debe ser > 0'); + if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); + $pdo->prepare('INSERT INTO tramites_hours (tramite_id, abogado, hrs, descripcion, autor_email) VALUES (?,?,?,?,?)') ->execute([$id, $abog, $hrs, $desc, $user['email']]); @@ -274,8 +358,13 @@ try { } if ($method === 'DELETE' && $action === 'delete') { + if (!Auth::canDelete()) err('Tu rol no permite eliminar trámites. Solo Socios y Abogados pueden.', 403); + $id = (int) ($_GET['id'] ?? 0); if (!$id) err('id requerido'); + + if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); + $pdo->prepare('DELETE FROM tramites_legales WHERE id = :id')->execute([':id' => $id]); ok(['deleted' => $id]); } diff --git a/public/tramites/index.php b/public/tramites/index.php index c26659e..c211628 100644 --- a/public/tramites/index.php +++ b/public/tramites/index.php @@ -1,8 +1,8 @@ prepare( + 'SELECT id, email, name, role, apellido, activo + FROM users WHERE email = :e LIMIT 1' + ); + $stmt->execute([':e' => $email]); + $row = $stmt->fetch(); + self::$userRowCache = $row ?: self::defaultRow(); + } catch (Throwable $e) { + error_log('Auth::userRow fetch failed: ' . $e->getMessage()); + self::$userRowCache = self::defaultRow(); + } + return self::$userRowCache; + } + + private static function defaultRow(): array { + return ['id' => 0, 'email' => '', 'name' => '', 'role' => 'usuario', 'apellido' => '', '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 isSocio(): bool { return self::role() === 'socio'; } + public static function isAbogado(): bool { return in_array(self::role(), ['socio','abogado'], true); } + public static function canCreate(): bool { return self::isAbogado(); } // socio + abogado pueden crear trámites + public static function canDelete(): bool { return self::isAbogado(); } // socio + abogado pueden eliminar + + public static function requireSocio(): void { + self::requireAuth(); + if (!self::isSocio()) { + $accepts = $_SERVER['HTTP_ACCEPT'] ?? ''; + $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + || str_contains($accepts, 'application/json'); + http_response_code(403); + if ($isAjax) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Acceso restringido a Socios']); + } else { + header('Content-Type: text/html; charset=utf-8'); + echo '403 — Restringido' + . '
' + . '

403 — Acceso restringido

' + . '

El Panel de Control es exclusivo para Socios. Si crees que deberías tener acceso, contacta al administrador.

' + . '← Volver al portal
'; + } + exit; + } + } + public static function buildLoginUrl(?string $returnTo = null): string { self::startSession(); $state = bin2hex(random_bytes(16)); @@ -77,25 +139,49 @@ final class Auth { return in_array($parts[1], array_map('strtolower', $allowed), true); } - public static function completeLogin(string $email, string $name): void { + /** + * Procesa el login. Retorna false si el usuario existe pero está desactivado + * (activo=0), en cuyo caso el caller debe mostrar un mensaje "cuenta + * deshabilitada" y NO crear sesión. + */ + public static function completeLogin(string $email, string $name): bool { + try { + $pdo = Db::pdo(); + $sel = $pdo->prepare('SELECT id, activo FROM users WHERE email = :e LIMIT 1'); + $sel->execute([':e' => $email]); + $existing = $sel->fetch(); + + if ($existing && (int)$existing['activo'] === 0) { + return false; + } + + if ($existing) { + $upd = $pdo->prepare( + 'UPDATE users SET name = :name, last_login_at = NOW() WHERE id = :id' + ); + $upd->execute([':name' => $name, ':id' => (int)$existing['id']]); + } else { + $ins = $pdo->prepare( + 'INSERT INTO users (email, name, role, activo, first_seen_at, last_login_at) + VALUES (:email, :name, :role, 1, NOW(), NOW())' + ); + // Nuevos usuarios entran como "usuario" (sin permisos de crear) por defecto. + // El Socio los promueve a abogado vía el Panel de Control si corresponde. + $ins->execute([':email' => $email, ':name' => $name, ':role' => 'usuario']); + } + } catch (Throwable $e) { + error_log('Auth::completeLogin upsert failed: ' . $e->getMessage()); + // No bloqueamos el login por un fallo de DB no crítico + } + self::startSession(); session_regenerate_id(true); $_SESSION['authenticated'] = true; $_SESSION['user_email'] = $email; $_SESSION['user_name'] = $name ?: $email; $_SESSION['auth_time'] = time(); - - try { - $pdo = Db::pdo(); - $stmt = $pdo->prepare( - 'INSERT INTO users (email, name, first_seen_at, last_login_at) - VALUES (:email, :name, NOW(), NOW()) - ON DUPLICATE KEY UPDATE name = VALUES(name), last_login_at = NOW()' - ); - $stmt->execute([':email' => $email, ':name' => $name]); - } catch (Throwable $e) { - error_log('users upsert failed: ' . $e->getMessage()); - } + self::$userRowCache = null; // invalidate cache so next userRow() call re-fetches + return true; } public static function logout(): void { diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..272994b --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,40 @@ + para que el JS tenga rol, lista de abogados, lista de + * usuarios involucrables, etc. sin hacer un fetch extra al cargar. + */ +final class Bootstrap { + public static function inject(string $viewPath): void { + $user = Auth::user(); + $payload = [ + 'me' => $user['email'], + 'name' => $user['name'], + 'role' => Auth::role(), + 'isSocio' => Auth::isSocio(), + 'isAbogado' => Auth::isAbogado(), + 'canCreate' => Auth::canCreate(), + 'canDelete' => Auth::canDelete(), + 'abogados' => Lookups::abogadoApellidos(), + 'users' => Lookups::involucrablesUsers(), + ]; + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP); + $inject = ''; + + $html = file_get_contents($viewPath); + if ($html === false) { + http_response_code(500); + exit('Vista no disponible.'); + } + $out = str_replace('', $inject . "\n", $html, $count); + if ($count === 0) { + $out = $inject . "\n" . $html; + } + echo $out; + } +} diff --git a/src/Lookups.php b/src/Lookups.php new file mode 100644 index 0000000..6763410 --- /dev/null +++ b/src/Lookups.php @@ -0,0 +1,59 @@ +query( + "SELECT DISTINCT apellido FROM users + WHERE activo = 1 + AND role IN ('socio','abogado') + AND apellido != '' + ORDER BY orden ASC, apellido ASC" + )->fetchAll(); + return array_map(fn($r) => $r['apellido'], $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 + FROM users + WHERE activo = 1 + AND email NOT LIKE 'placeholder-%' + ORDER BY name ASC, email ASC" + )->fetchAll(); + foreach ($rows as &$r) { $r['id'] = (int)$r['id']; } + return $rows; + } + + /** 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, + first_seen_at, last_login_at + FROM users + ORDER BY role DESC, apellido ASC, name ASC, email ASC" + )->fetchAll(); + foreach ($rows as &$r) { + $r['id'] = (int)$r['id']; + $r['activo'] = (int)$r['activo']; + $r['orden'] = (int)$r['orden']; + } + return $rows; + } + + /** id del usuario por email; null si no existe. */ + public static function userIdByEmail(string $email): ?int { + $stmt = Db::pdo()->prepare('SELECT id FROM users WHERE email = :e'); + $stmt->execute([':e' => $email]); + $id = $stmt->fetchColumn(); + return $id === false ? null : (int)$id; + } +} diff --git a/views/admin.html b/views/admin.html new file mode 100644 index 0000000..516058e --- /dev/null +++ b/views/admin.html @@ -0,0 +1,434 @@ + + + + + +Panel de Control — Donna AALN + + + + + + + +
+
+ + + Portal + + +
+
+
+
?
+ … +
+ Cerrar sesión +
+
+ +
+

Usuarios y permisos

+

Administra la lista unificada de usuarios del sistema. El campo Apellido es lo que aparece en el dropdown "Abogado Solicitante" (solo si el rol es Socio o Abogado). Los emails que empiezan con placeholder- 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.

+
+ +
+ + + + +
+ +
+
+ + + + + + + + + + + + + + +
EmailNombreApellidoRolActivoOrdenÚltimo login
+ +
+
+ + + +
+ + + + diff --git a/views/calendario.html b/views/calendario.html index 4e3a609..3d182ae 100644 --- a/views/calendario.html +++ b/views/calendario.html @@ -54,6 +54,11 @@ header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0 .btn-logout { font-size:11px; padding:4px 10px; border:1px solid rgba(255,255,255,.2); border-radius:6px; background:transparent; color:rgba(255,255,255,.7); transition:all .15s; } .btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; } +.btn-gear { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; + 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; cursor:pointer; font-family:'DM Sans',sans-serif; } +.btn-gear:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); } +.btn-gear svg { width:16px; height:16px; } .cal-toolbar { background:#fff; border-bottom:1px solid var(--gray-100); padding:12px 24px; display:flex; align-items:center; gap:14px; flex-wrap:wrap; box-shadow:var(--shadow); } @@ -273,6 +278,9 @@ footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px;
?
… + Cerrar sesión @@ -363,6 +371,14 @@ let search = ''; let selected = null; let authUser = { email:'', name:'' }; +// Gear icon solo visible para socios (window.DONNA inyectado por Bootstrap) +if (window.DONNA && window.DONNA.isSocio) { + document.addEventListener('DOMContentLoaded', () => { + const g = document.getElementById('btn-gear'); + if (g) g.style.display = 'inline-flex'; + }); +} + function $(id){ return document.getElementById(id); } function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>'); } function stripTime(d){ const o=new Date(d); o.setHours(0,0,0,0); return o; } diff --git a/views/escrituras.html b/views/escrituras.html index 4ab8b4b..55dc35b 100644 --- a/views/escrituras.html +++ b/views/escrituras.html @@ -77,6 +77,15 @@ header { transition:all .15s; } .btn-logout:hover { background:rgba(255,255,255,0.1); color:#fff; } +.btn-gear { + display:inline-flex; align-items:center; justify-content:center; + width:34px; height:34px; 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; cursor:pointer; + font-family:'DM Sans',sans-serif; +} +.btn-gear:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); } +.btn-gear svg { width:16px; height:16px; } /* BACK TO PORTAL */ .header-left { display:flex; align-items:center; gap:14px; } @@ -460,6 +469,9 @@ tr.editing-row td textarea.editable {
?
Cargando… + @@ -601,7 +613,14 @@ tr.editing-row td textarea.editable {