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:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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 = '/';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> </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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
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>
|
||||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user