feat(horas): nuevo módulo Reporte de Horas + icono reloj en headers

NUEVA APP /horas/
* public/horas/index.php — Bootstrap::inject del view
* public/horas/api.php — endpoint GET ?action=list que retorna:
  - entries: entradas detalladas con join a tramites_legales (link,
    título, cliente, departamento, estatus)
  - by_user: agregado por persona (solo socio/admin)
  - avail_users: dropdown de usuarios para filtrar (solo socio/admin)
  - total_hrs, is_socio, my_name
  Filtros: ?from=YYYY-MM-DD, ?to=YYYY-MM-DD, ?user=ID
* views/horas.html — UI con:
  - Header estandarizado (Portal, sync, user-chip, gear si socio, logout)
  - Toolbar de filtros: rango de fechas + dropdown usuario (solo socio)
    + atajos rápidos (Hoy / Semana / Mes / Año / Todo)
  - KPIs: Total horas, Entradas, Personas con horas (socio)
  - Tarjetas "Por persona" (solo socio), click filtra por ese usuario
  - Tabla detalle con link al trámite (?edit=CODIGO) + departamento +
    horas en h:mm + descripción

VISIBILIDAD
* Abogado/usuario ve solo sus horas (match por h.abogado = mi_name).
* Socio/admin ve todas las horas. Puede filtrar por persona.
* Admin oculto del dropdown de personas (queries excluyen role=admin).

ICONO DE RELOJ EN HEADERS
* Agregado .btn-clock (alias visual de .btn-gear) en:
  escrituras.html, tramites.html, calendario.html, admin.html,
  horas.html, public/index.php (landing).
* Posición: al lado del user-chip, antes del gear/logout.
* Visible para todos los autenticados (no restringido por rol).
This commit is contained in:
Soporte ZH Consultores
2026-06-05 00:56:10 +00:00
parent bbf91c7bec
commit 72c04ad594
8 changed files with 602 additions and 12 deletions
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Db.php';
Auth::requireAuth();
header('Content-Type: application/json; charset=utf-8');
function ok($d = null): void { echo json_encode(['ok'=>true, 'data'=>$d, 'user'=>Auth::user()], JSON_UNESCAPED_UNICODE); exit; }
function err(string $m, int $c = 400): void { http_response_code($c); echo json_encode(['ok'=>false, 'error'=>$m], JSON_UNESCAPED_UNICODE); exit; }
function clean_date(?string $v): ?string {
if (!$v) return null;
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$pdo = Db::pdo();
if ($method !== 'GET' || $action !== 'list') err('Acción no reconocida', 404);
try {
$from = clean_date($_GET['from'] ?? null);
$to = clean_date($_GET['to'] ?? null);
$userParam = isset($_GET['user']) ? (int)$_GET['user'] : 0;
// Determinar por qué nombre filtrar:
// - Socio/admin: por user seleccionado, o todos si user=0
// - Resto: forzado a sus propias horas (matched por su name)
$filterName = null;
if (Auth::isSocio()) {
if ($userParam > 0) {
$sel = $pdo->prepare('SELECT name FROM users WHERE id = :id');
$sel->execute([':id' => $userParam]);
$name = $sel->fetchColumn();
if ($name !== false && $name !== '') $filterName = (string)$name;
}
} else {
$filterName = Auth::userName();
if ($filterName === '') {
ok([
'entries' => [],
'by_user' => [],
'avail_users' => [],
'total_hrs' => 0,
'is_socio' => false,
'my_name' => '',
'note' => 'Tu usuario no tiene nombre asignado. Pídele al admin que lo complete en el Panel de Control.',
]);
}
}
$where = ['1=1'];
$params = [];
if ($filterName !== null) {
$where[] = 'h.abogado = :name';
$params[':name'] = $filterName;
}
if ($from) {
$where[] = 'h.fecha >= :from';
$params[':from'] = $from . ' 00:00:00';
}
if ($to) {
$where[] = 'h.fecha <= :to';
$params[':to'] = $to . ' 23:59:59';
}
$whereSql = implode(' AND ', $where);
$sql = "SELECT h.id, h.fecha, h.abogado, h.hrs, h.descripcion, h.autor_email,
h.tramite_id,
t.codigo AS tramite_codigo,
t.departamento AS tramite_departamento,
COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title,
t.cliente AS tramite_cliente,
t.estatus AS tramite_estatus
FROM tramites_hours h
JOIN tramites_legales t ON t.id = h.tramite_id
WHERE $whereSql
ORDER BY h.fecha DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = $stmt->fetchAll();
foreach ($entries as &$e) {
$e['id'] = (int)$e['id'];
$e['tramite_id'] = (int)$e['tramite_id'];
$e['hrs'] = (float)$e['hrs'];
}
unset($e);
$totalHrs = array_sum(array_column($entries, 'hrs'));
// Breakdown por usuario (solo socio/admin): ignora filtro de abogado pero
// respeta rango de fechas → permite ver "todos los usuarios" del período
$byUser = [];
if (Auth::isSocio()) {
$byWhere = ['1=1'];
$byParams = [];
if ($from) { $byWhere[] = 'h.fecha >= :from'; $byParams[':from'] = $from . ' 00:00:00'; }
if ($to) { $byWhere[] = 'h.fecha <= :to'; $byParams[':to'] = $to . ' 23:59:59'; }
$bySql = "SELECT h.abogado AS name, SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h
WHERE " . implode(' AND ', $byWhere) . "
GROUP BY h.abogado
ORDER BY total DESC";
$byStmt = $pdo->prepare($bySql);
$byStmt->execute($byParams);
$byUser = $byStmt->fetchAll();
foreach ($byUser as &$bu) {
$bu['total'] = (float)$bu['total'];
$bu['entries'] = (int)$bu['entries'];
}
unset($bu);
}
// Lista de usuarios para el dropdown de filtro (solo socios/admins)
$availUsers = [];
if (Auth::isSocio()) {
$availUsers = $pdo->query(
"SELECT id, name, email FROM users
WHERE activo = 1 AND name != '' AND role != 'admin'
ORDER BY name"
)->fetchAll();
foreach ($availUsers as &$u) $u['id'] = (int)$u['id'];
unset($u);
}
ok([
'entries' => $entries,
'by_user' => $byUser,
'avail_users' => $availUsers,
'total_hrs' => $totalHrs,
'is_socio' => Auth::isSocio(),
'my_name' => Auth::userName(),
]);
} catch (PDOException $e) {
error_log('horas db error: ' . $e->getMessage());
err('Error de base de datos', 500);
} catch (Throwable $e) {
error_log('horas error: ' . $e->getMessage());
err('Error interno', 500);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Bootstrap.php';
Auth::requireAuth();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
Bootstrap::inject(__DIR__ . '/../../views/horas.html');
+6 -3
View File
@@ -106,9 +106,9 @@ a{color:inherit;text-decoration:none;}
.user-email{font-size:10.5px;color:rgba(255,255,255,0.55);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.btn-logout{font-size:11px;padding:6px 12px;border:1px solid rgba(255,255,255,0.18);border-radius:18px;background:transparent;color:rgba(255,255,255,0.7);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;}
.btn-logout:hover{background:rgba(255,255,255,0.12);color:#fff;border-color:rgba(255,255,255,0.3);}
.btn-gear{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border:1px solid rgba(255,255,255,0.18);border-radius:8px;color:rgba(255,255,255,0.85);transition:all .15s;text-decoration:none;font-family:'DM Sans',sans-serif;margin-right:8px;}
.btn-gear:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.btn-gear svg{width:16px;height:16px;}
.btn-gear,.btn-clock{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border:1px solid rgba(255,255,255,0.18);border-radius:8px;color:rgba(255,255,255,0.85);transition:all .15s;text-decoration:none;font-family:'DM Sans',sans-serif;margin-right:8px;}
.btn-gear:hover,.btn-clock:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.btn-gear svg,.btn-clock svg{width:16px;height:16px;}
.dash-main{max-width:1180px;margin:0 auto;padding:48px 32px 80px;}
.dash-hero{margin-bottom:40px;}
@@ -203,6 +203,9 @@ a{color:inherit;text-decoration:none;}
</div>
</div>
<div style="display:flex;align-items:center;">
<a class="btn-clock" href="/horas/" title="Reporte de horas">
<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="10"/><polyline points="12 6 12 12 16 14"/></svg>
</a>
<?php if (Auth::isSocio()): ?>
<a class="btn-gear" href="/admin/" title="Panel de Control">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>