feat(admin): Registro de transacciones — log de auditoría de todo el sistema
NUEVA INFRAESTRUCTURA * SQL 010: tabla audit_log (id, fecha, user_email, user_name, modulo, accion, entity_type, entity_id, entity_label, detalles JSON, ip_addr) con índices por fecha/user/modulo/accion/entity. * src/AuditLog.php: helper estático con dos métodos: - log() lee actor de $_SESSION (uso normal post-auth) - logAs() recibe email/name explícito (para login/logout y CLI) Falla silenciosamente si la DB no responde — no rompe la operación principal por un fallo de logging. HOOKS EN MUTACIONES * Auth.php: registra 'login' al completar OAuth y 'logout' antes de destruir la sesión. * escrituras/api.php: registra create/update/delete con id+nombre. * tramites/api.php: registra 8 mutaciones — create/update/delete del trámite, set_status (con nuevo estatus en detalles), add_obs, add_hours/update_hours/delete_hours (con abogado y horas en detalles), todos referenciados por codigo (ADM-001, etc.). * admin/api.php: registra create_user/update_user/delete_user, con diff de cambios en update_user. * GETs (list_*) no se loguean — serían ruido. LECTOR * admin/api.php nuevo endpoint GET ?action=list_log que devuelve entradas con filtros from/to/user/modulo/accion/search (LIKE) + catálogos para dropdowns. Límite 500 por query. UI EN PANEL DE CONTROL * admin.html ahora tiene tabs: "Usuarios" (existente) y "Registro de transacciones" (nuevo). * Tab Registro: toolbar de filtros (rango fechas, usuario, módulo, acción, búsqueda libre, atajos Hoy/Semana/Mes/Todo) + tabla con Fecha·Hora · Usuario · Módulo (pill coloreado) · Acción · Entidad con label y tipo · Detalles JSON resumido.
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Db.php';
|
||||
require_once __DIR__ . '/../../src/Lookups.php';
|
||||
require_once __DIR__ . '/../../src/AuditLog.php';
|
||||
|
||||
Auth::requireSocio();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -72,6 +73,7 @@ try {
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['activo'] = (int)$row['activo'];
|
||||
$row['orden'] = (int)$row['orden'];
|
||||
AuditLog::log('admin', 'create_user', 'user', (string)$id, $email, ['role'=>$role, 'name'=>$name]);
|
||||
ok($row);
|
||||
}
|
||||
|
||||
@@ -133,6 +135,11 @@ try {
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['activo'] = (int)$row['activo'];
|
||||
$row['orden'] = (int)$row['orden'];
|
||||
$diff = [];
|
||||
if ($existing['role'] !== $newRole) $diff['role'] = ['from'=>$existing['role'], 'to'=>$newRole];
|
||||
if ((int)$existing['activo'] !== $newActivo) $diff['activo'] = ['from'=>(int)$existing['activo'], 'to'=>$newActivo];
|
||||
if ($existing['email'] !== $newEmail) $diff['email'] = ['from'=>$existing['email'], 'to'=>$newEmail];
|
||||
AuditLog::log('admin', 'update_user', 'user', (string)$id, $newEmail, $diff ?: null);
|
||||
ok($row);
|
||||
}
|
||||
|
||||
@@ -157,9 +164,54 @@ try {
|
||||
// hours.autor_email) permanecen como referencia muerta — eso es lo deseado para
|
||||
// preservar la trazabilidad de trámites antiguos.
|
||||
$pdo->prepare('DELETE FROM users WHERE id = :id')->execute([':id' => $id]);
|
||||
AuditLog::log('admin', 'delete_user', 'user', (string)$id, $u['email'], ['role'=>$u['role']]);
|
||||
ok(['deleted' => $id, 'email' => $u['email']]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $action === 'list_log') {
|
||||
$from = $_GET['from'] ?? '';
|
||||
$to = $_GET['to'] ?? '';
|
||||
$uEmail = trim((string)($_GET['user'] ?? ''));
|
||||
$modulo = trim((string)($_GET['modulo'] ?? ''));
|
||||
$accion = trim((string)($_GET['accion'] ?? ''));
|
||||
$search = trim((string)($_GET['search'] ?? ''));
|
||||
$limit = min(1000, max(50, (int)($_GET['limit'] ?? 500)));
|
||||
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { $where[] = 'fecha >= :from'; $params[':from'] = $from . ' 00:00:00'; }
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { $where[] = 'fecha <= :to'; $params[':to'] = $to . ' 23:59:59'; }
|
||||
if ($uEmail !== '') { $where[] = 'user_email = :ue'; $params[':ue'] = $uEmail; }
|
||||
if ($modulo !== '') { $where[] = 'modulo = :m'; $params[':m'] = $modulo; }
|
||||
if ($accion !== '') { $where[] = 'accion = :a'; $params[':a'] = $accion; }
|
||||
if ($search !== '') {
|
||||
$where[] = '(user_name LIKE :s OR user_email LIKE :s OR entity_label LIKE :s OR detalles LIKE :s)';
|
||||
$params[':s'] = '%' . $search . '%';
|
||||
}
|
||||
$sql = 'SELECT id, fecha, user_email, user_name, modulo, accion, entity_type, entity_id, entity_label, detalles, ip_addr
|
||||
FROM audit_log
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY fecha DESC LIMIT ' . $limit;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as &$r) { $r['id'] = (int)$r['id']; }
|
||||
|
||||
// Catálogos para los dropdowns de filtro
|
||||
$modulos = $pdo->query("SELECT DISTINCT modulo FROM audit_log ORDER BY modulo")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$acciones = $pdo->query("SELECT DISTINCT accion FROM audit_log ORDER BY accion")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$usuarios = $pdo->query("SELECT DISTINCT user_email, MAX(user_name) AS user_name
|
||||
FROM audit_log WHERE user_email != ''
|
||||
GROUP BY user_email ORDER BY user_email")->fetchAll();
|
||||
|
||||
ok([
|
||||
'entries' => $rows,
|
||||
'modulos' => $modulos,
|
||||
'acciones' => $acciones,
|
||||
'usuarios' => $usuarios,
|
||||
]);
|
||||
}
|
||||
|
||||
err('Acción no reconocida', 404);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Db.php';
|
||||
require_once __DIR__ . '/../../src/AuditLog.php';
|
||||
|
||||
if (($_GET['action'] ?? '') === 'logout') {
|
||||
header('Location: /auth/logout.php');
|
||||
@@ -73,7 +74,9 @@ try {
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok($row->fetch());
|
||||
$r = $row->fetch();
|
||||
AuditLog::log('escrituras', 'create', 'escritura', (string)$id, $r['tramite'] ?? '');
|
||||
ok($r);
|
||||
}
|
||||
|
||||
if ($method === 'PUT' && $action === 'update') {
|
||||
@@ -148,13 +151,20 @@ try {
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok($row->fetch());
|
||||
$r = $row->fetch();
|
||||
AuditLog::log('escrituras', 'update', 'escritura', (string)$id, $r['tramite'] ?? '');
|
||||
ok($r);
|
||||
}
|
||||
|
||||
if ($method === 'DELETE' && $action === 'delete') {
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if (!$id) err('ID requerido');
|
||||
// Fetch name before delete for the log
|
||||
$nameStmt = $pdo->prepare('SELECT tramite FROM escrituras_tramites WHERE id = :id');
|
||||
$nameStmt->execute([':id' => $id]);
|
||||
$tramiteName = (string)($nameStmt->fetchColumn() ?: '');
|
||||
$pdo->prepare('DELETE FROM escrituras_tramites WHERE id = :id')->execute([':id' => $id]);
|
||||
AuditLog::log('escrituras', 'delete', 'escritura', (string)$id, $tramiteName);
|
||||
ok(['deleted' => $id]);
|
||||
}
|
||||
|
||||
|
||||
+35
-3
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Db.php';
|
||||
require_once __DIR__ . '/../../src/AuditLog.php';
|
||||
|
||||
if (($_GET['action'] ?? '') === 'logout') {
|
||||
header('Location: /auth/logout.php');
|
||||
@@ -227,6 +228,8 @@ try {
|
||||
if ($myId > 0 && !in_array($myId, $involIds, true)) $involIds[] = $myId;
|
||||
syncInvolucrados($pdo, $id, $involIds, $user['email']);
|
||||
|
||||
AuditLog::log('tramites', 'create', 'tramite_legal', $codigo, $codigo, ['departamento'=>$dept, 'cliente'=>$b['cliente']??'']);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
@@ -290,6 +293,8 @@ try {
|
||||
syncInvolucrados($pdo, $id, array_map('intval', $b['involucrados']), $user['email']);
|
||||
}
|
||||
|
||||
AuditLog::log('tramites', 'update', 'tramite_legal', $before['codigo'], $before['codigo']);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
@@ -303,14 +308,16 @@ try {
|
||||
|
||||
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, codigo FROM tramites_legales WHERE id = :id');
|
||||
$cur->execute([':id' => $id]);
|
||||
$dept = $cur->fetchColumn();
|
||||
if (!$dept) err('Trámite no encontrado', 404);
|
||||
$curRow = $cur->fetch();
|
||||
if (!$curRow) err('Trámite no encontrado', 404);
|
||||
$dept = $curRow['departamento'];
|
||||
if (!in_array($val, STATUS_MAP[$dept], true)) err('Estatus inválido para el departamento');
|
||||
|
||||
$pdo->prepare('UPDATE tramites_legales SET estatus = :s, actualizado_por = :u WHERE id = :id')
|
||||
->execute([':s' => $val, ':u' => $user['email'], ':id' => $id]);
|
||||
AuditLog::log('tramites', 'set_status', 'tramite_legal', $curRow['codigo'], $curRow['codigo'], ['estatus'=>$val]);
|
||||
ok(['id' => $id, 'estatus' => $val]);
|
||||
}
|
||||
|
||||
@@ -330,6 +337,11 @@ try {
|
||||
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
|
||||
->execute([':u' => $user['email'], ':id' => $id]);
|
||||
|
||||
$codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id');
|
||||
$codSel->execute([':id' => $id]);
|
||||
$codigo = (string)$codSel->fetchColumn();
|
||||
AuditLog::log('tramites', 'add_obs', 'tramite_legal', $codigo, $codigo, ['abogado'=>$abog, 'texto'=>$texto]);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
@@ -352,6 +364,11 @@ try {
|
||||
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
|
||||
->execute([':u' => $user['email'], ':id' => $id]);
|
||||
|
||||
$codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id');
|
||||
$codSel->execute([':id' => $id]);
|
||||
$codigo = (string)$codSel->fetchColumn();
|
||||
AuditLog::log('tramites', 'add_hours', 'tramite_legal', $codigo, $codigo, ['abogado'=>$abog, 'hrs'=>$hrs]);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
@@ -383,6 +400,11 @@ try {
|
||||
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
|
||||
->execute([':u' => $user['email'], ':id' => $tramiteId]);
|
||||
|
||||
$codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id');
|
||||
$codSel->execute([':id' => $tramiteId]);
|
||||
$codigo = (string)$codSel->fetchColumn();
|
||||
AuditLog::log('tramites', 'update_hours', 'hour', (string)$hourId, $codigo, ['abogado'=>$abog, 'hrs'=>$hrs]);
|
||||
|
||||
$r = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$r->execute([':id' => $tramiteId]);
|
||||
ok(hydrate($pdo, $r->fetch()));
|
||||
@@ -405,6 +427,11 @@ try {
|
||||
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
|
||||
->execute([':u' => $user['email'], ':id' => $tramiteId]);
|
||||
|
||||
$codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id');
|
||||
$codSel->execute([':id' => $tramiteId]);
|
||||
$codigo = (string)$codSel->fetchColumn();
|
||||
AuditLog::log('tramites', 'delete_hours', 'hour', (string)$hourId, $codigo);
|
||||
|
||||
$r = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$r->execute([':id' => $tramiteId]);
|
||||
ok(hydrate($pdo, $r->fetch()));
|
||||
@@ -418,7 +445,12 @@ try {
|
||||
|
||||
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
|
||||
|
||||
$codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id');
|
||||
$codSel->execute([':id' => $id]);
|
||||
$codigo = (string)$codSel->fetchColumn();
|
||||
|
||||
$pdo->prepare('DELETE FROM tramites_legales WHERE id = :id')->execute([':id' => $id]);
|
||||
AuditLog::log('tramites', 'delete', 'tramite_legal', $codigo, $codigo);
|
||||
ok(['deleted' => $id]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- ============================================================
|
||||
-- Donna — Schema 010: audit_log para trazabilidad de transacciones
|
||||
-- ============================================================
|
||||
-- Registra login, logout, create, update, delete y demás mutaciones
|
||||
-- de todos los módulos (escrituras, tramites, admin, etc.) con su
|
||||
-- usuario, fecha, módulo, acción y referencia a la entidad afectada.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
fecha DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
user_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
modulo VARCHAR(50) NOT NULL,
|
||||
accion VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NULL,
|
||||
entity_id VARCHAR(50) NULL,
|
||||
entity_label VARCHAR(255) NULL,
|
||||
detalles TEXT NULL,
|
||||
ip_addr VARCHAR(45) NULL,
|
||||
|
||||
INDEX idx_fecha (fecha DESC),
|
||||
INDEX idx_user (user_email),
|
||||
INDEX idx_modulo (modulo),
|
||||
INDEX idx_accion (accion),
|
||||
INDEX idx_entity (entity_type, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Db.php';
|
||||
|
||||
/**
|
||||
* AuditLog — helper estático para registrar transacciones en audit_log.
|
||||
* Diseñado para fallar silenciosamente: si por alguna razón no se puede
|
||||
* loguear, no debe bloquear la operación principal.
|
||||
*/
|
||||
final class AuditLog {
|
||||
public static function log(
|
||||
string $modulo,
|
||||
string $accion,
|
||||
?string $entityType = null,
|
||||
?string $entityId = null,
|
||||
?string $entityLabel = null,
|
||||
?array $detalles = null
|
||||
): void {
|
||||
$email = $_SESSION['user_email'] ?? '';
|
||||
$name = $_SESSION['user_name'] ?? '';
|
||||
self::logAs($email, $name, $modulo, $accion, $entityType, $entityId, $entityLabel, $detalles);
|
||||
}
|
||||
|
||||
/** Para casos donde el actor no está en $_SESSION (ej. login mismo). */
|
||||
public static function logAs(
|
||||
string $email,
|
||||
string $name,
|
||||
string $modulo,
|
||||
string $accion,
|
||||
?string $entityType = null,
|
||||
?string $entityId = null,
|
||||
?string $entityLabel = null,
|
||||
?array $detalles = null
|
||||
): void {
|
||||
try {
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'INSERT INTO audit_log
|
||||
(user_email, user_name, modulo, accion, entity_type, entity_id, entity_label, detalles, ip_addr)
|
||||
VALUES
|
||||
(:email, :name, :modulo, :accion, :etype, :eid, :elabel, :det, :ip)'
|
||||
);
|
||||
$stmt->execute([
|
||||
':email' => $email,
|
||||
':name' => $name,
|
||||
':modulo' => $modulo,
|
||||
':accion' => $accion,
|
||||
':etype' => $entityType,
|
||||
':eid' => $entityId === null ? null : (string)$entityId,
|
||||
':elabel' => $entityLabel,
|
||||
':det' => $detalles !== null ? json_encode($detalles, JSON_UNESCAPED_UNICODE) : null,
|
||||
':ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('AuditLog failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
require_once __DIR__ . '/Db.php';
|
||||
require_once __DIR__ . '/AuditLog.php';
|
||||
|
||||
final class Auth {
|
||||
/** Cache por request del row de users del usuario autenticado */
|
||||
@@ -182,11 +183,18 @@ final class Auth {
|
||||
$_SESSION['user_name'] = $name ?: $email;
|
||||
$_SESSION['auth_time'] = time();
|
||||
self::$userRowCache = null; // invalidate cache so next userRow() call re-fetches
|
||||
|
||||
AuditLog::logAs($email, $name ?: $email, 'auth', 'login', 'user', null, $email);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function logout(): void {
|
||||
self::startSession();
|
||||
$email = $_SESSION['user_email'] ?? '';
|
||||
$name = $_SESSION['user_name'] ?? '';
|
||||
if ($email !== '') {
|
||||
AuditLog::logAs($email, $name, 'auth', 'logout', 'user', null, $email);
|
||||
}
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
|
||||
@@ -137,6 +137,38 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
|
||||
.toast.error { border-left-color:#ef5350; }
|
||||
|
||||
.empty { text-align:center; padding:40px; color:var(--gray-400); }
|
||||
|
||||
/* TABS */
|
||||
.tabs-wrap { max-width:1180px; margin:18px auto 0; padding:0 24px; display:flex; gap:4px; border-bottom:1px solid var(--gray-200); }
|
||||
.tab { padding:10px 18px; cursor:pointer; font-size:13px; font-weight:500; color:var(--gray-700);
|
||||
border-bottom:3px solid transparent; transition:all .15s; }
|
||||
.tab:hover { color:var(--navy); }
|
||||
.tab.active { color:var(--navy); border-bottom-color:var(--gold); font-weight:600; }
|
||||
.panel { display:none; }
|
||||
.panel.active { display:block; }
|
||||
|
||||
/* AUDIT LOG */
|
||||
.log-toolbar { max-width:1180px; margin:18px auto 12px; padding:0 24px; display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; }
|
||||
.log-tbl { max-width:1180px; margin:0 auto 32px; padding:0 24px; }
|
||||
.log-tbl table th, .log-tbl table td { white-space:normal; }
|
||||
.log-fecha { font-family:ui-monospace,monospace; font-size:11.5px; color:var(--gray-700); white-space:nowrap; }
|
||||
.log-user { font-weight:500; color:var(--gray-900); }
|
||||
.log-user-email { font-size:10.5px; color:var(--gray-400); display:block; }
|
||||
.log-mod { display:inline-block; padding:2px 9px; border-radius:12px; font-size:10.5px; font-weight:700;
|
||||
text-transform:uppercase; letter-spacing:.05em; color:#fff; }
|
||||
.mod-auth { background:#6c5b95; } .mod-escrituras { background:#1B3A6B; }
|
||||
.mod-tramites { background:#9E7C2E; } .mod-admin { background:#722f37; }
|
||||
.mod-default { background:#9B9A96; }
|
||||
.log-accion { font-family:ui-monospace,monospace; font-size:11.5px; color:var(--navy); font-weight:600; }
|
||||
.log-entity { font-size:12px; color:var(--gray-700); }
|
||||
.log-entity small { color:var(--gray-400); font-size:10.5px; }
|
||||
.log-detalles { font-size:11px; color:var(--gray-400); max-width:320px; word-break:break-word;
|
||||
font-family:ui-monospace,monospace; }
|
||||
.log-row-limit { padding:14px; text-align:center; font-size:11.5px; color:var(--gray-400);
|
||||
background:var(--off); border-top:1px solid var(--gray-100); }
|
||||
.tb-quick-log { font-size:11px; padding:6px 10px; background:var(--off); border:1px solid var(--gray-200);
|
||||
border-radius:6px; color:var(--gray-700); transition:all .15s; }
|
||||
.tb-quick-log:hover { background:var(--navy); color:#fff; border-color:var(--navy); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -167,6 +199,13 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<div class="tab active" data-tab="users" onclick="switchTab('users')">Usuarios</div>
|
||||
<div class="tab" data-tab="log" onclick="switchTab('log')">Registro de transacciones</div>
|
||||
</div>
|
||||
|
||||
<div class="panel active" id="panel-users">
|
||||
|
||||
<div class="intro">
|
||||
<h1>Usuarios y permisos</h1>
|
||||
<p>Administra la lista unificada de usuarios del sistema. El campo <b>Nombre completo</b> es lo que aparece en el dropdown "Abogado Solicitante" (solo si el rol es <b>Socio</b> o <b>Abogado</b>). Los emails que empiezan con <code>placeholder-</code> son los nombres sembrados al inicio para que los dropdowns no salieran vacíos; reemplázalos con los emails reales conforme los abogados se logueen.</p>
|
||||
@@ -209,6 +248,72 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /panel-users -->
|
||||
|
||||
<div class="panel" id="panel-log">
|
||||
<div class="intro">
|
||||
<h1>Registro de transacciones</h1>
|
||||
<p>Cada login, logout, creación, edición y eliminación queda registrada con el usuario que la ejecutó. Útil para auditoría y para entender quién hizo qué.</p>
|
||||
</div>
|
||||
|
||||
<div class="log-toolbar">
|
||||
<div class="tb-group">
|
||||
<label>Desde</label>
|
||||
<input type="date" id="log-from">
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<label>Hasta</label>
|
||||
<input type="date" id="log-to">
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<label>Usuario</label>
|
||||
<select id="log-user"><option value="">— Todos —</option></select>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<label>Módulo</label>
|
||||
<select id="log-modulo"><option value="">— Todos —</option></select>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<label>Acción</label>
|
||||
<select id="log-accion"><option value="">— Todas —</option></select>
|
||||
</div>
|
||||
<div class="tb-group" style="flex:1;min-width:160px;">
|
||||
<label>Búsqueda libre</label>
|
||||
<input type="search" id="log-search" placeholder="texto en nombre, entidad o detalles…">
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<label> </label>
|
||||
<div style="display:flex;gap:5px;">
|
||||
<button class="tb-quick-log" onclick="setLogQuick('today')">Hoy</button>
|
||||
<button class="tb-quick-log" onclick="setLogQuick('week')">Semana</button>
|
||||
<button class="tb-quick-log" onclick="setLogQuick('month')">Mes</button>
|
||||
<button class="tb-quick-log" onclick="setLogQuick('all')">Todo</button>
|
||||
<button class="btn-primary" onclick="loadLog()" style="padding:7px 14px;">Aplicar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-tbl">
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha · Hora</th>
|
||||
<th>Usuario</th>
|
||||
<th>Módulo</th>
|
||||
<th>Acción</th>
|
||||
<th>Entidad</th>
|
||||
<th>Detalles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log-tbody"></tbody>
|
||||
</table>
|
||||
<div id="log-empty" class="empty" style="display:none;">Sin transacciones en el período seleccionado.</div>
|
||||
<div id="log-rowinfo" class="log-row-limit" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /panel-log -->
|
||||
|
||||
<div class="modal-overlay" id="modal">
|
||||
<div class="modal">
|
||||
<header class="modal-head">
|
||||
@@ -481,6 +586,113 @@ document.addEventListener('keydown', e => {
|
||||
$(id).addEventListener('change', render);
|
||||
});
|
||||
|
||||
// ── TABS ─────────────────────────────────────────────────────
|
||||
function switchTab(name){
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
|
||||
if (name === 'log' && !logLoaded) { defaultLogDates(); loadLog(); logLoaded = true; }
|
||||
}
|
||||
|
||||
// ── AUDIT LOG ────────────────────────────────────────────────
|
||||
let logLoaded = false;
|
||||
const MOD_LABEL = { auth:'Auth', escrituras:'Escrituras', tramites:'Trámites', admin:'Admin', horas:'Horas', calendario:'Calendario' };
|
||||
const ACCION_LABEL = {
|
||||
login:'Login', logout:'Logout',
|
||||
create:'Crear', update:'Editar', delete:'Eliminar',
|
||||
create_user:'Crear usuario', update_user:'Editar usuario', delete_user:'Eliminar usuario',
|
||||
set_status:'Cambiar estatus', add_obs:'Agregar observación',
|
||||
add_hours:'Agregar horas', update_hours:'Editar horas', delete_hours:'Eliminar horas',
|
||||
};
|
||||
|
||||
function defaultLogDates(){
|
||||
const now = new Date();
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const ymd = d => d.toISOString().slice(0,10);
|
||||
$('log-from').value = ymd(first);
|
||||
$('log-to').value = ymd(now);
|
||||
}
|
||||
function setLogQuick(period){
|
||||
const now = new Date();
|
||||
let from, to = now;
|
||||
if (period === 'today') from = now;
|
||||
if (period === 'week') { const d = now.getDay() || 7; from = new Date(now); from.setDate(now.getDate()-d+1); }
|
||||
if (period === 'month') from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
if (period === 'all') { $('log-from').value = ''; $('log-to').value = ''; loadLog(); return; }
|
||||
const ymd = d => d.toISOString().slice(0,10);
|
||||
$('log-from').value = ymd(from);
|
||||
$('log-to').value = ymd(to);
|
||||
loadLog();
|
||||
}
|
||||
function fmtLogDate(dt){
|
||||
if(!dt) return '—';
|
||||
const d=new Date(dt); if(isNaN(d)) return dt;
|
||||
return d.toLocaleDateString('es-PA',{day:'2-digit',month:'2-digit',year:'numeric'})
|
||||
+' · '+d.toLocaleTimeString('es-PA',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
}
|
||||
async function loadLog(){
|
||||
const params = new URLSearchParams();
|
||||
['from','to','user','modulo','accion','search'].forEach(k => {
|
||||
const v = $('log-'+k).value;
|
||||
if (v) params.set(k, v);
|
||||
});
|
||||
try {
|
||||
const d = await apiCall('GET', 'list_log', null, params.toString());
|
||||
if (!d) return;
|
||||
// Poblar dropdowns con catálogos vistos
|
||||
populateLogDropdowns(d);
|
||||
renderLog(d.entries||[]);
|
||||
} catch(e){ toast('Error: '+e.message, 'error'); }
|
||||
}
|
||||
function populateLogDropdowns(d){
|
||||
// Usuarios (preserva selección)
|
||||
const uSel = $('log-user'); const uPrev = uSel.value;
|
||||
uSel.innerHTML = '<option value="">— Todos —</option>' +
|
||||
(d.usuarios||[]).map(u => `<option value="${esc(u.user_email)}">${esc(u.user_name || u.user_email)}</option>`).join('');
|
||||
uSel.value = uPrev;
|
||||
// Módulos
|
||||
const mSel = $('log-modulo'); const mPrev = mSel.value;
|
||||
mSel.innerHTML = '<option value="">— Todos —</option>' +
|
||||
(d.modulos||[]).map(m => `<option value="${esc(m)}">${esc(MOD_LABEL[m] || m)}</option>`).join('');
|
||||
mSel.value = mPrev;
|
||||
// Acciones
|
||||
const aSel = $('log-accion'); const aPrev = aSel.value;
|
||||
aSel.innerHTML = '<option value="">— Todas —</option>' +
|
||||
(d.acciones||[]).map(a => `<option value="${esc(a)}">${esc(ACCION_LABEL[a] || a)}</option>`).join('');
|
||||
aSel.value = aPrev;
|
||||
}
|
||||
function renderLog(entries){
|
||||
const tbody = $('log-tbody');
|
||||
const empty = $('log-empty');
|
||||
const info = $('log-rowinfo');
|
||||
if (!entries.length) { tbody.innerHTML=''; empty.style.display='block'; info.style.display='none'; return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = entries.map(e => {
|
||||
const modClass = MOD_LABEL[e.modulo] ? 'mod-' + e.modulo : 'mod-default';
|
||||
const detallesShort = e.detalles ? esc(e.detalles).substring(0, 280) : '';
|
||||
const entityCol = e.entity_label
|
||||
? `<b>${esc(e.entity_label)}</b>${e.entity_type?` <small>${esc(e.entity_type)}</small>`:''}`
|
||||
: (e.entity_type ? `<small>${esc(e.entity_type)}</small>` : '—');
|
||||
return `<tr>
|
||||
<td class="log-fecha">${esc(fmtLogDate(e.fecha))}</td>
|
||||
<td><span class="log-user">${esc(e.user_name || '—')}</span><span class="log-user-email">${esc(e.user_email || '')}</span></td>
|
||||
<td><span class="log-mod ${modClass}">${esc(MOD_LABEL[e.modulo] || e.modulo)}</span></td>
|
||||
<td class="log-accion">${esc(ACCION_LABEL[e.accion] || e.accion)}</td>
|
||||
<td class="log-entity">${entityCol}</td>
|
||||
<td class="log-detalles">${detallesShort}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
info.style.display = 'block';
|
||||
info.textContent = `Mostrando ${entries.length} transacciones` + (entries.length >= 500 ? ' (límite del servidor — ajusta los filtros para ver más).' : '.');
|
||||
}
|
||||
|
||||
['log-from','log-to','log-user','log-modulo','log-accion'].forEach(id => {
|
||||
$(id).addEventListener('change', loadLog);
|
||||
});
|
||||
$('log-search').addEventListener('input', () => {
|
||||
clearTimeout(window._logSearchTimer);
|
||||
window._logSearchTimer = setTimeout(loadLog, 350);
|
||||
});
|
||||
|
||||
loadUsers();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user