feat(perms): permisología completa con 3 roles (Socio/Abogado/Usuario) + Panel de Control

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
  <script>window.DONNA={me,name,role,isSocio,isAbogado,canCreate,canDelete,
  abogados,users}</script> antes de </head> 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/.
This commit is contained in:
Soporte ZH Consultores
2026-06-04 23:14:07 +00:00
parent cd9dae98bb
commit 2db8f23ba5
16 changed files with 1088 additions and 37 deletions
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Db.php';
require_once __DIR__ . '/../../src/Lookups.php';
Auth::requireSocio();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
const ROLES = ['socio','abogado','usuario'];
function ok($data = null): void {
echo json_encode(['ok' => 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);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Bootstrap.php';
Auth::requireSocio();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
Bootstrap::inject(__DIR__ . '/../../views/admin.html');
+3 -1
View File
@@ -60,7 +60,9 @@ if (!Auth::emailDomainAllowed($email)) {
deny('Esta cuenta no está autorizada para Donna. Solo dominios corporativos de AALN.', 403); deny('Esta cuenta no está autorizada para Donna. Solo dominios corporativos de AALN.', 403);
} }
Auth::completeLogin($email, $name); if (!Auth::completeLogin($email, $name)) {
deny('Tu cuenta de Donna está desactivada. Contacta al administrador para reactivarla.', 403);
}
$returnTo = Auth::popReturnTo(); $returnTo = Auth::popReturnTo();
if (!str_starts_with($returnTo, '/')) $returnTo = '/'; if (!str_starts_with($returnTo, '/')) $returnTo = '/';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php'; require_once __DIR__ . '/../../src/Bootstrap.php';
Auth::requireAuth(); Auth::requireAuth();
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
readfile(__DIR__ . '/../../views/calendario.html'); Bootstrap::inject(__DIR__ . '/../../views/calendario.html');
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php'; require_once __DIR__ . '/../../src/Bootstrap.php';
Auth::requireAuth(); Auth::requireAuth();
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
readfile(__DIR__ . '/../../views/escrituras.html'); Bootstrap::inject(__DIR__ . '/../../views/escrituras.html');
+90 -1
View File
@@ -78,6 +78,7 @@ function next_codigo(PDO $pdo, string $dept): string {
function hydrate(PDO $pdo, array $row): array { function hydrate(PDO $pdo, array $row): array {
$tid = (int) $row['id']; $tid = (int) $row['id'];
$obs = $pdo->prepare('SELECT id, texto, abogado, autor_email, fecha FROM tramites_obs WHERE tramite_id = :t ORDER BY fecha ASC'); $obs = $pdo->prepare('SELECT id, texto, abogado, autor_email, fecha FROM tramites_obs WHERE tramite_id = :t ORDER BY fecha ASC');
$obs->execute([':t' => $tid]); $obs->execute([':t' => $tid]);
$row['obs'] = $obs->fetchAll(); $row['obs'] = $obs->fetchAll();
@@ -89,17 +90,79 @@ function hydrate(PDO $pdo, array $row): array {
return $h; return $h;
}, $hrs->fetchAll()); }, $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; 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']; $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
$user = Auth::user(); $user = Auth::user();
$myId = Auth::userId();
$pdo = Db::pdo(); $pdo = Db::pdo();
try { try {
if ($method === 'GET' && $action === 'list') { 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' => []]; $grouped = ['adm' => [], 'corp' => [], 'mig' => [], 'lit' => []];
foreach ($rows as $r) { foreach ($rows as $r) {
$grouped[$r['departamento']][] = hydrate($pdo, $r); $grouped[$r['departamento']][] = hydrate($pdo, $r);
@@ -108,6 +171,8 @@ try {
} }
if ($method === 'POST' && $action === 'create') { if ($method === 'POST' && $action === 'create') {
if (!Auth::canCreate()) err('Tu rol no permite crear trámites. Solo Socios y Abogados pueden.', 403);
$b = body(); $b = body();
$dept = dept_of($b); $dept = dept_of($b);
@@ -156,6 +221,12 @@ try {
':user' => $user['email'], ':user' => $user['email'],
]); ]);
$id = (int) $pdo->lastInsertId(); $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 = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$row->execute([':id' => $id]); $row->execute([':id' => $id]);
ok(hydrate($pdo, $row->fetch())); ok(hydrate($pdo, $row->fetch()));
@@ -166,6 +237,8 @@ try {
$id = (int) ($b['_id'] ?? 0); $id = (int) ($b['_id'] ?? 0);
if (!$id) err('id requerido'); 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 = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$cur->execute([':id' => $id]); $cur->execute([':id' => $id]);
$before = $cur->fetch(); $before = $cur->fetch();
@@ -212,6 +285,11 @@ try {
':user' => $user['email'], ':user' => $user['email'],
':id' => $id, ':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 = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$row->execute([':id' => $id]); $row->execute([':id' => $id]);
ok(hydrate($pdo, $row->fetch())); ok(hydrate($pdo, $row->fetch()));
@@ -223,6 +301,8 @@ try {
$val = (string) ($b['estatus'] ?? ''); $val = (string) ($b['estatus'] ?? '');
if (!$id) err('id requerido'); 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 = $pdo->prepare('SELECT departamento FROM tramites_legales WHERE id = :id');
$cur->execute([':id' => $id]); $cur->execute([':id' => $id]);
$dept = $cur->fetchColumn(); $dept = $cur->fetchColumn();
@@ -242,6 +322,8 @@ try {
if (!$id) err('id requerido'); if (!$id) err('id requerido');
if ($texto === '') err('texto 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 (?,?,?,?)') $pdo->prepare('INSERT INTO tramites_obs (tramite_id, texto, abogado, autor_email) VALUES (?,?,?,?)')
->execute([$id, $texto, $abog, $user['email']]); ->execute([$id, $texto, $abog, $user['email']]);
@@ -262,6 +344,8 @@ try {
if (!$id) err('id requerido'); if (!$id) err('id requerido');
if ($hrs <= 0) err('horas debe ser > 0'); 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 (?,?,?,?,?)') $pdo->prepare('INSERT INTO tramites_hours (tramite_id, abogado, hrs, descripcion, autor_email) VALUES (?,?,?,?,?)')
->execute([$id, $abog, $hrs, $desc, $user['email']]); ->execute([$id, $abog, $hrs, $desc, $user['email']]);
@@ -274,8 +358,13 @@ try {
} }
if ($method === 'DELETE' && $action === 'delete') { 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); $id = (int) ($_GET['id'] ?? 0);
if (!$id) err('id requerido'); 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]); $pdo->prepare('DELETE FROM tramites_legales WHERE id = :id')->execute([':id' => $id]);
ok(['deleted' => $id]); ok(['deleted' => $id]);
} }
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php'; require_once __DIR__ . '/../../src/Bootstrap.php';
Auth::requireAuth(); Auth::requireAuth();
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
readfile(__DIR__ . '/../../views/tramites.html'); Bootstrap::inject(__DIR__ . '/../../views/tramites.html');
+52
View File
@@ -0,0 +1,52 @@
-- ============================================================
-- Donna — Schema 005: Roles + apellido en tabla users
-- ============================================================
-- Modelo de permisos:
-- socio = modo Dios, ve y edita todo, acceso al Panel de Control
-- abogado = crea casos. Ve/edita los que creó, los donde es abogado
-- solicitante (matching users.apellido = tramite.abogado),
-- y los donde fue involucrado. Puede eliminar lo suyo.
-- usuario = pasante/asistente. No crea ni elimina. Solo ve/edita
-- donde fue marcado como involucrado.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS role ENUM('socio','abogado','usuario')
NOT NULL DEFAULT 'usuario' AFTER name,
ADD COLUMN IF NOT EXISTS apellido VARCHAR(100) NOT NULL DEFAULT '' AFTER role,
ADD COLUMN IF NOT EXISTS activo TINYINT(1) NOT NULL DEFAULT 1 AFTER apellido,
ADD COLUMN IF NOT EXISTS orden INT NOT NULL DEFAULT 0 AFTER activo,
ADD INDEX IF NOT EXISTS idx_role (role),
ADD INDEX IF NOT EXISTS idx_apellido (apellido),
ADD INDEX IF NOT EXISTS idx_activo (activo);
-- Bootstrap: el admin (cabrego@aal-law.com) queda como Socio con apellido Abrego
INSERT INTO users (email, name, role, apellido, activo, first_seen_at, last_login_at)
VALUES ('cabrego@aal-law.com', 'Carlos Abrego', 'socio', 'Abrego', 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE role='socio', activo=1, apellido='Abrego';
-- Seed: 15 apellidos placeholder para que los dropdowns no salgan vacíos.
-- Los emails son sintéticos (placeholder-*); el Socio los reemplaza desde el
-- Panel de Control conforme los abogados reales se vayan logueando.
INSERT IGNORE INTO users (email, name, role, apellido, activo, orden) VALUES
('placeholder-arias@aal-law.com', '(Asignar email real)', 'abogado', 'Arias', 1, 1),
('placeholder-abrego@aal-law.com', '(Asignar email real)', 'abogado', 'Abrego', 1, 2),
('placeholder-lopez@aal-law.com', '(Asignar email real)', 'abogado', 'Lopez', 1, 3),
('placeholder-altafulla@aal-law.com', '(Asignar email real)', 'abogado', 'Altafulla', 1, 4),
('placeholder-alvarez@aal-law.com', '(Asignar email real)', 'abogado', 'Álvarez', 1, 5),
('placeholder-gutierrez@aal-law.com', '(Asignar email real)', 'abogado', 'Gutiérrez', 1, 6),
('placeholder-cedeno@aal-law.com', '(Asignar email real)', 'abogado', 'Cedeño', 1, 7),
('placeholder-mariscal@aal-law.com', '(Asignar email real)', 'abogado', 'Mariscal', 1, 8),
('placeholder-avila@aal-law.com', '(Asignar email real)', 'abogado', 'Ávila', 1, 9),
('placeholder-agudo@aal-law.com', '(Asignar email real)', 'abogado', 'Agudo', 1, 10),
('placeholder-martinez@aal-law.com', '(Asignar email real)', 'abogado', 'Martínez', 1, 11),
('placeholder-degracia@aal-law.com', '(Asignar email real)', 'abogado', 'De Gracia', 1, 12),
('placeholder-gondola@aal-law.com', '(Asignar email real)', 'abogado', 'Góndola', 1, 13),
('placeholder-gonzalez@aal-law.com', '(Asignar email real)', 'abogado', 'González', 1, 14),
('placeholder-valdes@aal-law.com', '(Asignar email real)', 'abogado', 'Valdés', 1, 15);
-- Si el placeholder de Abrego ya existe y hay un usuario real Abrego con apellido asignado,
-- desactivar el placeholder para evitar duplicados visibles en dropdowns.
UPDATE users p
JOIN users r ON r.apellido = p.apellido AND r.id != p.id AND r.email NOT LIKE 'placeholder-%'
SET p.activo = 0
WHERE p.email LIKE 'placeholder-%' AND p.activo = 1;
+19
View File
@@ -0,0 +1,19 @@
-- ============================================================
-- Donna — Schema 006: Involucrados en Trámites Legales
-- ============================================================
-- Tabla N:M que vincula usuarios (de cualquier rol) a un trámite.
-- La visibilidad de un trámite es:
-- (role=socio) OR (creado_por=mi_email)
-- OR (abogado=mi_apellido si soy abogado activo)
-- OR (estoy en tramites_involucrados)
CREATE TABLE IF NOT EXISTS tramites_involucrados (
tramite_id INT NOT NULL,
user_id INT NOT NULL,
added_by VARCHAR(255) NOT NULL DEFAULT '',
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tramite_id, user_id),
INDEX idx_inv_user (user_id),
CONSTRAINT fk_inv_tramite FOREIGN KEY (tramite_id) REFERENCES tramites_legales(id) ON DELETE CASCADE,
CONSTRAINT fk_inv_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+99 -13
View File
@@ -5,6 +5,9 @@ require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/Db.php'; require_once __DIR__ . '/Db.php';
final class Auth { final class Auth {
/** Cache por request del row de users del usuario autenticado */
private static ?array $userRowCache = null;
public static function startSession(): void { public static function startSession(): void {
if (session_status() === PHP_SESSION_ACTIVE) return; if (session_status() === PHP_SESSION_ACTIVE) return;
@@ -37,6 +40,65 @@ final class Auth {
]; ];
} }
/** Fetch del row de users del usuario actual (cached per request). */
public static function userRow(): array {
if (self::$userRowCache !== null) return self::$userRowCache;
$email = $_SESSION['user_email'] ?? '';
if (!$email) return self::$userRowCache = self::defaultRow();
try {
$stmt = Db::pdo()->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 '<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><title>403 — Restringido</title>'
. '<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#F7F6F3;color:#1A1916;}'
. '.box{text-align:center;max-width:420px;padding:40px;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);}'
. 'h1{color:#c0392b;margin:0 0 12px;font-size:24px;}p{color:#666;margin:0 0 20px;line-height:1.5;}'
. 'a{display:inline-block;padding:10px 24px;background:#1B3A6B;color:#fff;border-radius:8px;text-decoration:none;}'
. 'a:hover{background:#112446;}</style></head><body><div class="box">'
. '<h1>403 — Acceso restringido</h1>'
. '<p>El Panel de Control es exclusivo para Socios. Si crees que deberías tener acceso, contacta al administrador.</p>'
. '<a href="/">← Volver al portal</a></div></body></html>';
}
exit;
}
}
public static function buildLoginUrl(?string $returnTo = null): string { public static function buildLoginUrl(?string $returnTo = null): string {
self::startSession(); self::startSession();
$state = bin2hex(random_bytes(16)); $state = bin2hex(random_bytes(16));
@@ -77,25 +139,49 @@ final class Auth {
return in_array($parts[1], array_map('strtolower', $allowed), true); 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(); self::startSession();
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['user_email'] = $email; $_SESSION['user_email'] = $email;
$_SESSION['user_name'] = $name ?: $email; $_SESSION['user_name'] = $name ?: $email;
$_SESSION['auth_time'] = time(); $_SESSION['auth_time'] = time();
self::$userRowCache = null; // invalidate cache so next userRow() call re-fetches
try { return true;
$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());
}
} }
public static function logout(): void { public static function logout(): void {
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/Lookups.php';
/**
* Bootstrap — sirve una vista HTML estática con `window.DONNA` inyectado
* en <head> 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 = '<script>window.DONNA = ' . $json . ';</script>';
$html = file_get_contents($viewPath);
if ($html === false) {
http_response_code(500);
exit('Vista no disponible.');
}
$out = str_replace('</head>', $inject . "\n</head>", $html, $count);
if ($count === 0) {
$out = $inject . "\n" . $html;
}
echo $out;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Db.php';
/**
* Lookups — vistas de solo lectura sobre la tabla users para poblar
* dropdowns y resolver mapeo email ↔ apellido.
*/
final class Lookups {
/** Apellidos para el dropdown "Abogado Solicitante" (socios + abogados activos). */
public static function abogadoApellidos(): array {
$rows = Db::pdo()->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;
}
}
+434
View File
@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panel de Control — Donna AALN</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--navy:#1B3A6B; --navy-d:#112446; --navy-l:#2D5090;
--gold:#C9A84C; --gold-l:#E8C96A; --gold-d:#9E7C2E;
--off:#F7F6F3;
--gray-100:#EEEDE9; --gray-200:#D6D4CC; --gray-400:#9B9A96;
--gray-700:#4A4946; --gray-900:#1A1916;
--red:#c0392b; --orange:#e67e22; --green:#27ae60;
--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.06);
}
*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'DM Sans',system-ui,sans-serif; color:var(--gray-900); background:var(--off); font-size:13.5px; }
a { color:inherit; text-decoration:none; }
button { font-family:inherit; cursor:pointer; }
code { font-family:ui-monospace,monospace; background:var(--gray-100); padding:1px 5px; border-radius:3px; font-size:11.5px; }
header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0 24px;
display:flex; align-items:center; justify-content:space-between; height:58px;
position:sticky; top:0; z-index:200; }
.header-left { display:flex; align-items:center; gap:14px; }
.btn-back { display:inline-flex; align-items:center; gap:6px;
background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18);
color:rgba(255,255,255,.9); padding:6px 12px 6px 9px; border-radius:8px;
font-size:12px; transition:all .15s; white-space:nowrap; }
.btn-back:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); }
.btn-back svg { width:14px; height:14px; }
.logo { display:flex; align-items:center; gap:14px; }
.logo-mark { width:32px; height:32px; background:var(--gold); border-radius:6px;
display:flex; align-items:center; justify-content:center;
font-family:'DM Serif Display',serif; font-size:15px; color:var(--navy); }
.logo-text { display:flex; flex-direction:column; line-height:1.2; }
.logo-firm { font-size:11px; font-weight:600; color:var(--gold); letter-spacing:.08em; text-transform:uppercase; }
.logo-tool { font-size:13px; font-weight:400; color:rgba(255,255,255,.85); }
.header-right { display:flex; align-items:center; gap:16px; }
.user-chip { display:flex; align-items:center; gap:8px; background:rgba(255,255,255,.1);
border:1px solid rgba(255,255,255,.15); border-radius:20px; padding:5px 12px 5px 6px; }
.user-avatar { width:24px; height:24px; border-radius:50%; background:var(--gold); color:var(--navy);
display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
.user-name { font-size:12px; color:rgba(255,255,255,.85); max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.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; }
.intro { max-width:1100px; margin:24px auto 0; padding:0 24px; }
.intro h1 { font-family:'DM Serif Display',serif; font-size:30px; color:var(--navy); font-weight:400; margin-bottom:6px; }
.intro p { font-size:13.5px; color:var(--gray-700); line-height:1.55; max-width:820px; }
.toolbar { max-width:1100px; margin:18px auto 0; padding:0 24px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.toolbar input, .toolbar select { padding:7px 11px; border:1px solid var(--gray-200); border-radius:8px; font-family:inherit; font-size:12.5px; background:#fff; }
.toolbar input { flex:1; min-width:200px; max-width:320px; }
.btn-primary { padding:7px 16px; background:var(--navy); color:#fff; border:none; border-radius:8px;
font-size:13px; font-weight:500; transition:all .15s; }
.btn-primary:hover { background:var(--navy-d); }
.btn-secondary { padding:7px 14px; background:transparent; border:1px solid var(--gray-200); color:var(--gray-700); border-radius:8px; font-size:13px; }
.btn-secondary:hover { background:var(--gray-100); }
.tbl-card { max-width:1100px; margin:18px auto 32px; padding:0 24px; }
.tbl-wrap { background:#fff; border:1px solid var(--gray-100); border-radius:12px; overflow:hidden; box-shadow:var(--shadow); }
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th { background:var(--navy); color:rgba(255,255,255,.9); padding:10px 12px; text-align:left;
font-size:10.5px; font-weight:600; text-transform:uppercase; letter-spacing:.07em;
white-space:nowrap; border-right:1px solid rgba(255,255,255,.06); }
th:last-child { border-right:none; }
td { padding:9px 12px; border-bottom:1px solid var(--gray-100); color:var(--gray-700); vertical-align:middle; }
tr:last-child td { border-bottom:none; }
tr:hover td { background:#F9FAFC; }
tr.inactive td { color:var(--gray-400); background:#FAFAF7; font-style:italic; }
tr.is-me td { background:#FFFBEB; font-weight:500; }
.role-pill { display:inline-block; padding:2px 9px; border-radius:14px; font-size:10.5px; font-weight:700;
letter-spacing:.04em; text-transform:uppercase; }
.role-socio { background:#FCE4EC; color:#880E4F; }
.role-abogado { background:#E1F5EE; color:#085041; }
.role-usuario { background:#EEEDE9; color:#5F5E5A; }
.activo-pill { display:inline-block; padding:2px 8px; border-radius:14px; font-size:10.5px; font-weight:600; }
.activo-1 { background:#D4EDDA; color:#155724; }
.activo-0 { background:#F5C2C7; color:#842029; }
.placeholder { font-style:italic; color:var(--gray-400); font-size:11px; }
.actions { display:flex; gap:6px; }
.btn-icon { width:28px; height:28px; border:1px solid var(--gray-200); border-radius:6px; background:#fff;
color:var(--gray-700); display:inline-flex; align-items:center; justify-content:center; transition:all .15s; }
.btn-icon:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); }
.last-login { font-size:11px; color:var(--gray-400); white-space:nowrap; }
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(17,36,70,.6);
z-index:1000; align-items:center; justify-content:center; padding:24px; }
.modal-overlay.open { display:flex; }
.modal { background:#fff; border-radius:14px; width:100%; max-width:520px; max-height:90vh;
overflow:hidden; display:flex; flex-direction:column; box-shadow:0 20px 60px rgba(0,0,0,.3); }
.modal-head { background:var(--navy); color:#fff; padding:16px 22px; display:flex;
justify-content:space-between; align-items:center; border-bottom:2px solid var(--gold); }
.modal-head h3 { font-family:'DM Serif Display',serif; font-size:18px; font-weight:400; margin:0; }
.modal-head .close { background:transparent; border:none; color:rgba(255,255,255,.7); font-size:22px; line-height:1; }
.modal-head .close:hover { color:#fff; }
.modal-body { padding:18px 22px; overflow-y:auto; flex:1; }
.fg { margin-bottom:14px; }
.fg label { display:block; font-size:10.5px; font-weight:600; color:var(--navy);
margin-bottom:5px; text-transform:uppercase; letter-spacing:.08em; }
.fg input, .fg select { width:100%; padding:8px 11px; border:1px solid var(--gray-200);
border-radius:8px; font-family:'DM Sans',sans-serif; font-size:13px; background:#fff; }
.fg input:focus, .fg select:focus { outline:none; border-color:var(--navy-l);
box-shadow:0 0 0 3px rgba(27,58,107,.10); }
.fg-hint { font-size:10.5px; color:var(--gray-400); margin-top:4px; line-height:1.4; }
.fg-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.fg-check { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--gray-700); cursor:pointer; }
.fg-check input { width:auto; margin:0; }
.modal-foot { padding:14px 22px; border-top:1px solid var(--gray-100); display:flex; justify-content:flex-end;
gap:10px; background:var(--off); }
.toast { position:fixed; bottom:24px; right:24px; background:var(--navy-d); color:#fff;
padding:11px 18px; border-radius:8px; font-size:12.5px; border-left:3px solid var(--gold);
box-shadow:0 4px 20px rgba(0,0,0,.2); transform:translateY(20px); opacity:0;
transition:all .25s; z-index:2000; }
.toast.show { transform:translateY(0); opacity:1; }
.toast.error { border-left-color:#ef5350; }
.empty { text-align:center; padding:40px; color:var(--gray-400); }
</style>
</head>
<body>
<header>
<div class="header-left">
<a class="btn-back" href="/" title="Volver al portal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Portal
</a>
<div class="logo">
<div class="logo-mark">A</div>
<div class="logo-text">
<span class="logo-firm">AALN Law</span>
<span class="logo-tool">Panel de Control</span>
</div>
</div>
</div>
<div class="header-right">
<div class="user-chip">
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">…</span>
</div>
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
</div>
</header>
<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>
</div>
<div class="toolbar">
<input type="search" id="search" placeholder="Buscar por email, nombre o apellido…">
<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">
<option value="">— Activos e inactivos —</option>
<option value="1">Solo activos</option>
<option value="0">Solo inactivos</option>
</select>
<button class="btn-primary" onclick="openCreate()">+ Nuevo usuario</button>
</div>
<div class="tbl-card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Email</th>
<th>Nombre</th>
<th>Apellido</th>
<th>Rol</th>
<th>Activo</th>
<th>Orden</th>
<th>Último login</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" style="display:none;">Sin usuarios que coincidan con los filtros.</div>
</div>
</div>
<div class="modal-overlay" id="modal" onclick="if(event.target.id==='modal')closeModal()">
<div class="modal">
<header class="modal-head">
<h3 id="modal-title">Nuevo usuario</h3>
<button class="close" onclick="closeModal()" title="Esc">✕</button>
</header>
<div class="modal-body">
<div class="fg">
<label>Email *</label>
<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>
</div>
<div class="fg">
<label>Rol</label>
<select id="f-role-edit">
<option value="usuario">Usuario (pasante / asistente)</option>
<option value="abogado">Abogado</option>
<option value="socio">Socio (modo Dios)</option>
</select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>Orden</label>
<input type="number" id="f-orden" value="0" step="1">
<div class="fg-hint">Para ordenar el dropdown manualmente.</div>
</div>
<div class="fg">
<label>&nbsp;</label>
<label class="fg-check"><input type="checkbox" id="f-activo" checked> Activo — puede entrar a Donna</label>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn-secondary" onclick="closeModal()">Cancelar</button>
<button class="btn-primary" id="btn-save" onclick="saveUser()">Guardar</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const ROLES = ['socio','abogado','usuario'];
const ROLE_LABEL = { socio:'Socio', abogado:'Abogado', usuario:'Usuario' };
const MY_EMAIL = (window.DONNA && window.DONNA.me) || '';
let users = [];
let editingId = null;
let authUser = { email: MY_EMAIL, name: (window.DONNA && window.DONNA.name) || '' };
function $(id){ return document.getElementById(id); }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function toast(msg, type='ok'){
const el=$('toast'); el.textContent=msg;
el.className='toast show'+(type==='error'?' error':'');
setTimeout(()=>el.className='toast'+(type==='error'?' error':''),3500);
}
function initials(text){
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
return ((parts[0]||'').charAt(0)+(parts[1]||'').charAt(0)).toUpperCase()||'?';
}
function fmtDate(dt){
if(!dt) return '—';
const d=new Date(dt); if(isNaN(d)) return dt;
return d.toLocaleDateString('es-PA',{day:'2-digit',month:'short',year:'numeric'})
+' '+d.toLocaleTimeString('es-PA',{hour:'2-digit',minute:'2-digit'});
}
function setUserChip(){
$('user-avatar').textContent = initials(authUser.name || authUser.email);
$('user-name').textContent = authUser.name || authUser.email || '—';
$('user-name').title = authUser.email;
}
async function apiCall(method, action, body=null, query=''){
const url = '/admin/api.php?action=' + action + (query?'&'+query:'');
const opts = { method, headers:{Accept:'application/json'}, credentials:'same-origin' };
if(body){ opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); }
const res = await fetch(url, opts);
let j;
try { j = await res.json(); } catch(e){ throw new Error('Respuesta inválida'); }
if(res.status===401 && j.auth_required){
window.location.href = j.login_url || '/auth/login.php?return_to=/admin/';
return null;
}
if(!res.ok || !j.ok) throw new Error(j.error || ('HTTP '+res.status));
if(j.user) authUser = j.user;
return j.data;
}
async function loadUsers(){
try {
users = (await apiCall('GET','list_users')) || [];
setUserChip();
render();
} catch(e){
toast('Error al cargar usuarios: '+e.message, 'error');
}
}
function visibleUsers(){
const q = $('search').value.toLowerCase().trim();
const fR = $('f-role').value;
const fA = $('f-activo').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();
if (!blob.includes(q)) return false;
}
return true;
});
}
function render(){
const tbody = $('tbody');
const list = visibleUsers();
if (!list.length) {
tbody.innerHTML = '';
$('empty').style.display = 'block';
return;
}
$('empty').style.display = 'none';
tbody.innerHTML = list.map(u => {
const isMe = u.email === MY_EMAIL;
const isPlaceholder = (u.email||'').startsWith('placeholder-');
const rowCls = (u.activo === 0 ? 'inactive' : '') + (isMe ? ' is-me' : '');
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>
<td class="last-login">${u.last_login_at ? fmtDate(u.last_login_at) : '<span class="placeholder">Nunca</span>'}</td>
<td><div class="actions"><button class="btn-icon" title="Editar" onclick="openEdit(${u.id})">✎</button></div></td>
</tr>`;
}).join('');
}
function openCreate(){
editingId = null;
$('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;
$('hint-email').textContent = 'El email debe coincidir exactamente con la cuenta de Mailcow que el usuario usa para entrar.';
$('modal').classList.add('open');
setTimeout(()=>$('f-email').focus(), 50);
}
function openEdit(id){
const u = users.find(x => x.id === id);
if (!u) return;
editingId = u.id;
$('modal-title').textContent = 'Editar usuario · ' + u.email;
$('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;
$('hint-email').textContent = (u.email||'').startsWith('placeholder-')
? 'Reemplaza este email placeholder por el email real del abogado.'
: 'Cambiar el email afecta la asociación con Mailcow OAuth.';
$('modal').classList.add('open');
setTimeout(()=>$('f-name').focus(), 50);
}
function closeModal(){
$('modal').classList.remove('open');
editingId = null;
}
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,
};
if (!payload.email) { toast('Email requerido','error'); $('f-email').focus(); return; }
const btn = $('btn-save');
btn.disabled = true;
try {
let saved;
if (editingId) {
payload._id = editingId;
saved = await apiCall('PUT','update_user', payload);
} else {
saved = await apiCall('POST','create_user', payload);
}
if (!saved) return;
const idx = users.findIndex(u => u.id === saved.id);
if (idx >= 0) users[idx] = saved; else users.unshift(saved);
closeModal();
render();
toast(editingId ? 'Usuario actualizado' : 'Usuario creado');
} catch(e){
toast('Error: '+e.message, 'error');
} finally {
btn.disabled = false;
}
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
['search','f-role','f-activo'].forEach(id => {
$(id).addEventListener('input', render);
$(id).addEventListener('change', render);
});
loadUsers();
</script>
</body>
</html>
+16
View File
@@ -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; .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; } background:transparent; color:rgba(255,255,255,.7); transition:all .15s; }
.btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; } .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; .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); } 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;
<div class="user-avatar" id="user-avatar">?</div> <div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">…</span> <span class="user-name" id="user-name">…</span>
</div> </div>
<a class="btn-gear" href="/admin/" id="btn-gear" title="Panel de Control" style="display:none;">
<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>
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a> <a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
</div> </div>
</header> </header>
@@ -363,6 +371,14 @@ let search = '';
let selected = null; let selected = null;
let authUser = { email:'', name:'' }; 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 $(id){ return document.getElementById(id); }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function stripTime(d){ const o=new Date(d); o.setHours(0,0,0,0); return o; } function stripTime(d){ const o=new Date(d); o.setHours(0,0,0,0); return o; }
+20 -1
View File
@@ -77,6 +77,15 @@ header {
transition:all .15s; transition:all .15s;
} }
.btn-logout:hover { background:rgba(255,255,255,0.1); color:#fff; } .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 */ /* BACK TO PORTAL */
.header-left { display:flex; align-items:center; gap:14px; } .header-left { display:flex; align-items:center; gap:14px; }
@@ -460,6 +469,9 @@ tr.editing-row td textarea.editable {
<div class="user-avatar" id="user-avatar">?</div> <div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">Cargando…</span> <span class="user-name" id="user-name">Cargando…</span>
</div> </div>
<a class="btn-gear" href="/admin/" id="btn-gear" title="Panel de Control" style="display:none;">
<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>
<button class="btn-logout" onclick="doLogout()">Cerrar sesión</button> <button class="btn-logout" onclick="doLogout()">Cerrar sesión</button>
</div> </div>
</header> </header>
@@ -601,7 +613,14 @@ tr.editing-row td textarea.editable {
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script> <script>
const ABOGADOS = ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés']; const ABOGADOS = (window.DONNA && window.DONNA.abogados) || ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés'];
// Gear icon visible solo para socios
if (window.DONNA && window.DONNA.isSocio) {
document.addEventListener('DOMContentLoaded', () => {
const g = document.getElementById('btn-gear');
if (g) g.style.display = 'inline-flex';
});
}
const ESTATUS = ['Solicitado','Recibido','En Proceso','Protocolizado','En Registro','Inscrito']; const ESTATUS = ['Solicitado','Recibido','En Proceso','Protocolizado','En Registro','Inscrito'];
const API = 'api.php'; const API = 'api.php';
const POLL_MS = 15000; const POLL_MS = 15000;
+110 -15
View File
@@ -33,6 +33,19 @@ header{background:var(--navy);border-bottom:3px solid var(--gold);padding:0 24px
.user-name{font-size:12px;color:rgba(255,255,255,0.85);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .user-name{font-size:12px;color:rgba(255,255,255,0.85);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.btn-logout{font-size:11px;padding:4px 10px;border:1px solid rgba(255,255,255,0.20);border-radius:6px;background:transparent;color:rgba(255,255,255,0.70);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;text-decoration:none;display:inline-block;line-height:1.6;} .btn-logout{font-size:11px;padding:4px 10px;border:1px solid rgba(255,255,255,0.20);border-radius:6px;background:transparent;color:rgba(255,255,255,0.70);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;text-decoration:none;display:inline-block;line-height:1.6;}
.btn-logout:hover{background:rgba(255,255,255,0.10);color:#fff;border-color:rgba(255,255,255,0.30);} .btn-logout:hover{background:rgba(255,255,255,0.10);color:#fff;border-color:rgba(255,255,255,0.30);}
.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;}
.inv-stack{display:inline-flex;align-items:center;gap:3px;}
.inv-avatar{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:var(--navy);color:#fff;font-size:9.5px;font-weight:700;border:1.5px solid #fff;cursor:default;font-family:'DM Sans',sans-serif;}
.inv-more{font-size:10px;color:var(--gray);margin-left:3px;}
.inv-picker{display:flex;flex-direction:column;gap:6px;}
.inv-chips{display:flex;flex-wrap:wrap;gap:5px;padding:6px;min-height:36px;border:1px solid var(--border);border-radius:4px;background:#fafbfc;}
.inv-chips:empty::after{content:'Ningún involucrado seleccionado';color:#999;font-size:11px;padding:3px;align-self:center;}
.inv-chip{display:inline-flex;align-items:center;gap:4px;background:var(--navy);color:#fff;padding:3px 4px 3px 10px;border-radius:14px;font-size:11px;}
.inv-chip button{background:transparent;border:none;color:rgba(255,255,255,.7);cursor:pointer;padding:0 5px;font-size:14px;line-height:1;}
.inv-chip button:hover{color:#fff;}
.inv-picker select{width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:#fff;}
.tabs{display:flex;background:var(--navy);padding:0 16px;gap:2px;border-bottom:3px solid var(--gold);} .tabs{display:flex;background:var(--navy);padding:0 16px;gap:2px;border-bottom:3px solid var(--gold);}
.tab{padding:10px 18px;color:rgba(255,255,255,.65);cursor:pointer;font-size:12.5px;font-weight:600;border-radius:4px 4px 0 0;transition:.2s;white-space:nowrap;} .tab{padding:10px 18px;color:rgba(255,255,255,.65);cursor:pointer;font-size:12.5px;font-weight:600;border-radius:4px 4px 0 0;transition:.2s;white-space:nowrap;}
.tab:hover{color:#fff;background:rgba(255,255,255,.1);} .tab:hover{color:#fff;background:rgba(255,255,255,.1);}
@@ -148,6 +161,9 @@ tr.expanded>td{background:#fffdf5;}
<div class="user-avatar" id="user-avatar">?</div> <div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">Cargando…</span> <span class="user-name" id="user-name">Cargando…</span>
</div> </div>
<a class="btn-gear" href="/admin/" id="btn-gear" title="Panel de Control" style="display:none;">
<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>
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a> <a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
</div> </div>
</header> </header>
@@ -181,7 +197,21 @@ tr.expanded>td{background:#fffdf5;}
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<script> <script>
const ABOGADOS = ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés']; const ABOGADOS = (window.DONNA && window.DONNA.abogados) || ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés'];
const USERS = (window.DONNA && window.DONNA.users) || [];
const MY_EMAIL = (window.DONNA && window.DONNA.me) || '';
const MY_ID = (USERS.find(u => u.email === MY_EMAIL) || {}).id || 0;
const CAN_CREATE = !!(window.DONNA && window.DONNA.canCreate);
const CAN_DELETE = !!(window.DONNA && window.DONNA.canDelete);
const IS_SOCIO = !!(window.DONNA && window.DONNA.isSocio);
let currentInvIds = new Set();
// Mostrar gear icon si el usuario es socio
if (IS_SOCIO) {
document.addEventListener('DOMContentLoaded', () => {
const g = document.getElementById('btn-gear');
if (g) g.style.display = 'inline-flex';
});
}
const PROVINCIAS = ['Bocas del Toro','Chiriquí','Coclé','Colón','Darién','Herrera','Los Santos','Panamá','Panamá Oeste','Veraguas','Comarca Ngäbe-Buglé','Comarca Guna Yala','Comarca Emberá-Wounaan']; const PROVINCIAS = ['Bocas del Toro','Chiriquí','Coclé','Colón','Darién','Herrera','Los Santos','Panamá','Panamá Oeste','Veraguas','Comarca Ngäbe-Buglé','Comarca Guna Yala','Comarca Emberá-Wounaan'];
const STATUS_MAP = { const STATUS_MAP = {
adm: ['Solicitado','Recibido','En Proceso','Notificado','Recurso','Culminado','Facturado'], adm: ['Solicitado','Recibido','En Proceso','Notificado','Recurso','Culminado','Facturado'],
@@ -371,7 +401,7 @@ function renderPanel(dept){
<option value="">— Esfera —</option> <option value="">— Esfera —</option>
<option${fEsfera==='Civil'?' selected':''}>Civil</option><option${fEsfera==='Penal'?' selected':''}>Penal</option> <option${fEsfera==='Civil'?' selected':''}>Civil</option><option${fEsfera==='Penal'?' selected':''}>Penal</option>
</select>`:''} </select>`:''}
<button class="btn btn-primary" onclick="openModal('${dept}',null)">+ Nuevo</button> ${CAN_CREATE ? `<button class="btn btn-primary" onclick="openModal('${dept}',null)">+ Nuevo</button>` : ''}
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
@@ -385,10 +415,23 @@ function renderPanel(dept){
function buildHeaders(dept){ function buildHeaders(dept){
const base=`<th></th><th>ID</th>`; const base=`<th></th><th>ID</th>`;
if(dept==='adm') return base+`<th>Institución</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`; if(dept==='adm') return base+`<th>Institución</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Involucrados</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`;
if(dept==='corp') return base+`<th>Tipo Documento</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`; if(dept==='corp') return base+`<th>Tipo Documento</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Involucrados</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`;
if(dept==='mig') return base+`<th>Tipo Trámite</th><th>Cliente</th><th>Abogado</th><th>Doc. Completa</th><th>Perm. Trabajo</th><th>Dependientes</th><th>Estatus</th><th>Fecha Solicitud</th><th>Vencimiento</th><th>Horas</th><th>Acciones</th>`; if(dept==='mig') return base+`<th>Tipo Trámite</th><th>Cliente</th><th>Abogado</th><th>Involucrados</th><th>Doc. Completa</th><th>Perm. Trabajo</th><th>Dependientes</th><th>Estatus</th><th>Fecha Solicitud</th><th>Vencimiento</th><th>Horas</th><th>Acciones</th>`;
if(dept==='lit') return base+`<th>Proceso / Juzgado</th><th>Cliente</th><th>Abogado</th><th>Esfera</th><th>Instancia</th><th>Doc. Completa</th><th>Incidentes</th><th>Amparo</th><th>Estatus</th><th>Fecha Solicitud</th><th>Término</th><th>Horas</th><th>Acciones</th>`; if(dept==='lit') return base+`<th>Proceso / Juzgado</th><th>Cliente</th><th>Abogado</th><th>Involucrados</th><th>Esfera</th><th>Instancia</th><th>Doc. Completa</th><th>Incidentes</th><th>Amparo</th><th>Estatus</th><th>Fecha Solicitud</th><th>Término</th><th>Horas</th><th>Acciones</th>`;
}
function initialsOf(text){
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
return ((parts[0]||'').charAt(0)+(parts[1]||'').charAt(0)).toUpperCase()||'?';
}
function involucradosCell(invs){
if(!invs || !invs.length) return '<span style="color:var(--gray)">—</span>';
const top = invs.slice(0,3).map(u =>
`<span class="inv-avatar" title="${esc(u.name||u.email)} (${esc(u.email)})">${esc(initialsOf(u.name||u.email))}</span>`
).join('');
const more = invs.length>3 ? `<span class="inv-more">+${invs.length-3}</span>` : '';
return `<span class="inv-stack">${top}${more}</span>`;
} }
function statusSlug(s){return (s||'').toLowerCase().replace(/\s+/g,'').replace(/\./g,'').replace(/[éèê]/g,'e').replace(/[áàâ]/g,'a').replace(/[íì]/g,'i').replace(/[óò]/g,'o').replace(/[úù]/g,'u');} function statusSlug(s){return (s||'').toLowerCase().replace(/\s+/g,'').replace(/\./g,'').replace(/[éèê]/g,'e').replace(/[áàâ]/g,'a').replace(/[íì]/g,'i').replace(/[óò]/g,'o').replace(/[úù]/g,'u');}
@@ -410,15 +453,17 @@ function venceAlert(fecha){
function buildRow(dept,r){ function buildRow(dept,r){
let cells=''; let cells='';
if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`; const invCell = `<td>${involucradosCell(r.involucrados)}</td>`;
if(dept==='corp') cells=`<td>${esc(r.tipoDocumento)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`; if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`;
if(dept==='mig') cells=`<td>${esc(r.tipoTramite)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.permisoTrabajo)}</td><td>${yesnoBadge(r.dependientes)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaVencimiento)||'—'}${venceAlert(r.fechaVencimiento)}</td><td>${totalHours(r)} h</td>`; if(dept==='corp') cells=`<td>${esc(r.tipoDocumento)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`;
if(dept==='mig') cells=`<td>${esc(r.tipoTramite)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.permisoTrabajo)}</td><td>${yesnoBadge(r.dependientes)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaVencimiento)||'—'}${venceAlert(r.fechaVencimiento)}</td><td>${totalHours(r)} h</td>`;
if(dept==='lit'){ if(dept==='lit'){
const esfera=r.esfera||''; const esfera=r.esfera||'';
const inst=r.instancia||''; const inst=r.instancia||'';
cells=`<td><b>${esc(r.tipoProceso)||'—'}</b><br><small>${esc(r.juzgado)||'—'} · ${esc(r.circuito)||'—'}</small></td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${esfera?`<span class="badge b-${esfera.toLowerCase()}">${esfera}</span>`:'—'}</td><td>${inst?`<span class="badge b-${statusSlug(inst)}">${inst}</span>`:'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.incidentes)}</td><td>${yesnoBadge(r.amparo)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaTermino)||'—'}${venceAlert(r.fechaTermino)}</td><td>${totalHours(r)} h</td>`; cells=`<td><b>${esc(r.tipoProceso)||'—'}</b><br><small>${esc(r.juzgado)||'—'} · ${esc(r.circuito)||'—'}</small></td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${esfera?`<span class="badge b-${esfera.toLowerCase()}">${esfera}</span>`:'—'}</td><td>${inst?`<span class="badge b-${statusSlug(inst)}">${inst}</span>`:'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.incidentes)}</td><td>${yesnoBadge(r.amparo)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaTermino)||'—'}${venceAlert(r.fechaTermino)}</td><td>${totalHours(r)} h</td>`;
} }
const actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> <button class="btn btn-danger btn-sm" onclick="deleteRow('${dept}','${r.id}');event.stopPropagation()">✕</button></td>`; const delBtn = CAN_DELETE ? `<button class="btn btn-danger btn-sm" onclick="deleteRow('${dept}','${r.id}');event.stopPropagation()">✕</button>` : '';
const actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> ${delBtn}</td>`;
return `<tr id="row-${r.id}" onclick="toggleDetail(event,'${dept}','${r.id}')"> return `<tr id="row-${r.id}" onclick="toggleDetail(event,'${dept}','${r.id}')">
<td><button class="toggle-btn" id="tog-${r.id}">▶</button></td> <td><button class="toggle-btn" id="tog-${r.id}">▶</button></td>
<td><b>${esc(r.id)}</b></td> <td><b>${esc(r.id)}</b></td>
@@ -466,6 +511,7 @@ function buildDetail(dept,r){
${ficha} ${ficha}
<p><b>Cliente:</b> ${esc(r.cliente)||'—'}</p> <p><b>Cliente:</b> ${esc(r.cliente)||'—'}</p>
<p><b>Abogado:</b> ${esc(r.abogado)||'—'}</p> <p><b>Abogado:</b> ${esc(r.abogado)||'—'}</p>
<p><b>Involucrados:</b> ${(r.involucrados&&r.involucrados.length)?r.involucrados.map(u=>esc(u.name||u.email)).join(', '):'<span style="color:var(--gray)">—</span>'}</p>
<p><b>Fecha Solicitud:</b> ${esc(r.fechaSolicitud)||'—'}</p> <p><b>Fecha Solicitud:</b> ${esc(r.fechaSolicitud)||'—'}</p>
<p><b>Estatus:</b> ${statusSel}</p> <p><b>Estatus:</b> ${statusSel}</p>
</div> </div>
@@ -575,9 +621,19 @@ function openModal(dept,codigo){
_modalDept=dept; _modalDept=dept;
const r=codigo?findRow(dept,codigo):null; const r=codigo?findRow(dept,codigo):null;
editingId=r?r._id:null; editingId=r?r._id:null;
// Inicializar involucrados: si edita, los actuales; si crea, auto-incluir al usuario actual
const initialIds = r ? (r.involucrados||[]).map(u => Number(u.id))
: (MY_ID > 0 ? [MY_ID] : []);
currentInvIds = new Set(initialIds);
document.getElementById('modal-title').textContent=r?('Editar '+r.id):('Nuevo Trámite — '+DEPT_LABELS[dept]); document.getElementById('modal-title').textContent=r?('Editar '+r.id):('Nuevo Trámite — '+DEPT_LABELS[dept]);
document.getElementById('modal-body').innerHTML=buildForm(dept,r); document.getElementById('modal-body').innerHTML=buildForm(dept,r);
document.getElementById('modal').classList.add('open'); document.getElementById('modal').classList.add('open');
// Hook the change handler on the picker dropdown (rendered as part of buildForm)
setTimeout(()=>{
const sel = document.getElementById('inv-add');
if(sel) sel.addEventListener('change', e => addInv(e.target));
renderInvChips();
}, 30);
} }
function closeModal(){document.getElementById('modal').classList.remove('open');editingId=null;} function closeModal(){document.getElementById('modal').classList.remove('open');editingId=null;}
@@ -589,11 +645,44 @@ function selBlank(arr,val,id){
function inp(id,val,type='text',ph=''){return `<input type="${type}" id="${id}" value="${esc(val||'')}" placeholder="${esc(ph)}">`;} function inp(id,val,type='text',ph=''){return `<input type="${type}" id="${id}" value="${esc(val||'')}" placeholder="${esc(ph)}">`;}
function fld(label,html){return `<div class="form-group"><label>${label}</label>${html}</div>`;} function fld(label,html){return `<div class="form-group"><label>${label}</label>${html}</div>`;}
function buildInvolucradosPicker(){
return `<div class="inv-picker">
<div class="inv-chips" id="inv-chips"></div>
<select id="inv-add"><option value="">+ Agregar involucrado…</option></select>
</div>`;
}
function renderInvChips(){
const chips = document.getElementById('inv-chips');
const sel = document.getElementById('inv-add');
if(!chips || !sel) return;
const ids = [...currentInvIds];
chips.innerHTML = ids.map(id => {
const u = USERS.find(x => x.id === id);
if(!u) return '';
return `<span class="inv-chip" title="${esc(u.email)}">${esc(u.name||u.email)}<button type="button" onclick="removeInv(${id})">✕</button></span>`;
}).join('');
const available = USERS.filter(u => !currentInvIds.has(u.id));
sel.innerHTML = '<option value="">+ Agregar involucrado…</option>' +
available.map(u => `<option value="${u.id}">${esc(u.name||u.email)} (${esc(u.email)})</option>`).join('');
}
function addInv(selEl){
const id = parseInt(selEl.value, 10);
if(!id) return;
currentInvIds.add(id);
selEl.value='';
renderInvChips();
}
function removeInv(id){
currentInvIds.delete(parseInt(id, 10));
renderInvChips();
}
function buildForm(dept,r){ function buildForm(dept,r){
const abogSel=sel(ABOGADOS,r?.abogado,'f-abogado'); const abogSel=sel(ABOGADOS,r?.abogado,'f-abogado');
const instrTxt=`<textarea id="f-instrucciones">${esc(r?.instrucciones||'')}</textarea>`; const instrTxt=`<textarea id="f-instrucciones">${esc(r?.instrucciones||'')}</textarea>`;
const clienteInp=inp('f-cliente',r?.cliente,'text','Nombre del cliente'); const clienteInp=inp('f-cliente',r?.cliente,'text','Nombre del cliente');
const estatusSel=sel(STATUS_MAP[dept],r?.estatus,'f-estatus'); const estatusSel=sel(STATUS_MAP[dept],r?.estatus,'f-estatus');
const invPicker=buildInvolucradosPicker();
if(dept==='adm') return ` if(dept==='adm') return `
${fld('Institución',inp('f-institucion',r?.institucion,'text','Ej: Municipio de Panamá'))} ${fld('Institución',inp('f-institucion',r?.institucion,'text','Ej: Municipio de Panamá'))}
@@ -603,7 +692,8 @@ function buildForm(dept,r){
${fld('Cliente',clienteInp)} ${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)} ${fld('Abogado Solicitante',abogSel)}
</div> </div>
${fld('Estatus',estatusSel)}`; ${fld('Estatus',estatusSel)}
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
if(dept==='corp') return ` if(dept==='corp') return `
${fld('Tipo de Documento',inp('f-tipoDocumento',r?.tipoDocumento,'text','Ej: Contrato, Acta, Estatutos...'))} ${fld('Tipo de Documento',inp('f-tipoDocumento',r?.tipoDocumento,'text','Ej: Contrato, Acta, Estatutos...'))}
@@ -613,7 +703,8 @@ function buildForm(dept,r){
${fld('Cliente',clienteInp)} ${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)} ${fld('Abogado Solicitante',abogSel)}
</div> </div>
${fld('Estatus',estatusSel)}`; ${fld('Estatus',estatusSel)}
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
if(dept==='mig') return ` if(dept==='mig') return `
${fld('Tipo de Trámite',inp('f-tipoTramite',r?.tipoTramite,'text','Ej: Residencia, Visa, Naturalización...'))} ${fld('Tipo de Trámite',inp('f-tipoTramite',r?.tipoTramite,'text','Ej: Residencia, Visa, Naturalización...'))}
@@ -630,7 +721,8 @@ function buildForm(dept,r){
${fld('Dependientes',selBlank(SI_NO,r?.dependientes,'f-dependientes'))} ${fld('Dependientes',selBlank(SI_NO,r?.dependientes,'f-dependientes'))}
${fld('Fecha de Vencimiento',inp('f-fechaVencimiento',r?.fechaVencimiento,'date'))} ${fld('Fecha de Vencimiento',inp('f-fechaVencimiento',r?.fechaVencimiento,'date'))}
</div> </div>
${fld('Estatus',estatusSel)}`; ${fld('Estatus',estatusSel)}
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
if(dept==='lit') return ` if(dept==='lit') return `
<div class="form-row"> <div class="form-row">
@@ -655,7 +747,8 @@ function buildForm(dept,r){
${fld('Amparo',selBlank(SI_NO,r?.amparo,'f-amparo'))} ${fld('Amparo',selBlank(SI_NO,r?.amparo,'f-amparo'))}
${fld('Fecha de Término',inp('f-fechaTermino',r?.fechaTermino,'date'))} ${fld('Fecha de Término',inp('f-fechaTermino',r?.fechaTermino,'date'))}
</div> </div>
${fld('Estatus',estatusSel)}`; ${fld('Estatus',estatusSel)}
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
} }
function g(id){const el=document.getElementById(id);return el?el.value:'';} function g(id){const el=document.getElementById(id);return el?el.value:'';}
@@ -689,6 +782,8 @@ async function saveModal(){
payload.amparo = g('f-amparo'); payload.amparo = g('f-amparo');
payload.fechaTermino = g('f-fechaTermino'); payload.fechaTermino = g('f-fechaTermino');
} }
// Involucrados: array de user_ids; el backend valida que existan y estén activos
payload.involucrados = [...currentInvIds];
try { try {
let saved; let saved;