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>
+8
View File
@@ -49,6 +49,11 @@ header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0
.btn-logout { font-size:11px; padding:4px 10px; border:1px solid rgba(255,255,255,.2); border-radius:6px;
background:transparent; color:rgba(255,255,255,.7); transition:all .15s; }
.btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; }
.btn-clock { 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; }
.btn-clock:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); }
.btn-clock svg { width:16px; height:16px; }
.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; }
@@ -155,6 +160,9 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">…</span>
</div>
<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>
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
</div>
</header>
+6 -3
View File
@@ -54,11 +54,11 @@ header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0
.btn-logout { font-size:11px; padding:4px 10px; border:1px solid rgba(255,255,255,.2); border-radius:6px;
background:transparent; color:rgba(255,255,255,.7); transition:all .15s; }
.btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; }
.btn-gear { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px;
.btn-gear, .btn-clock { 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; }
.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; }
.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); }
@@ -278,6 +278,9 @@ footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px;
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">…</span>
</div>
<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>
<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>
+6 -3
View File
@@ -77,15 +77,15 @@ header {
transition:all .15s;
}
.btn-logout:hover { background:rgba(255,255,255,0.1); color:#fff; }
.btn-gear {
.btn-gear, .btn-clock {
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; }
.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; }
/* BACK TO PORTAL */
.header-left { display:flex; align-items:center; gap:14px; }
@@ -469,6 +469,9 @@ tr.editing-row td textarea.editable {
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">Cargando…</span>
</div>
<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>
<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>
+418
View File
@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reporte de Horas — AALN Donna</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;}
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:12px;}
.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;text-decoration:none;}
.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;}
.btn-gear:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.btn-gear svg{width:16px;height:16px;}
.page{max-width:1180px;margin:0 auto;padding: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:780px;margin-bottom:14px;}
.toolbar{background:#fff;border:1px solid var(--gray-100);border-radius:10px;padding:14px 18px;
display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;box-shadow:var(--shadow);margin-bottom:18px;}
.tb-group{display:flex;flex-direction:column;gap:4px;}
.tb-group label{font-size:10.5px;font-weight:600;color:var(--gray-400);
text-transform:uppercase;letter-spacing:.08em;}
.tb-group input,.tb-group select{padding:7px 11px;border:1px solid var(--gray-200);
border-radius:8px;font-family:inherit;font-size:12.5px;background:#fff;}
.tb-group input:focus,.tb-group select:focus{outline:none;border-color:var(--navy-l);box-shadow:0 0 0 3px rgba(27,58,107,.08);}
.tb-quick{display:flex;gap:6px;flex-wrap:wrap;align-items:flex-end;}
.quick-btn{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;}
.quick-btn:hover{background:var(--navy);color:#fff;border-color:var(--navy);}
.tb-spacer{flex:1;}
.btn-primary{padding:8px 18px;background:var(--navy);color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;}
.btn-primary:hover{background:var(--navy-d);}
.kpi-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:18px;}
.kpi{background:#fff;border:1px solid var(--gray-100);border-radius:10px;padding:14px 18px;
border-left:4px solid var(--navy);box-shadow:var(--shadow);}
.kpi-label{font-size:10.5px;font-weight:600;color:var(--gray-400);text-transform:uppercase;letter-spacing:.07em;}
.kpi-val{font-family:'DM Serif Display',serif;font-size:30px;color:var(--navy);line-height:1.1;margin-top:2px;}
.kpi-sub{font-size:11px;color:var(--gray-400);margin-top:2px;}
.section-label{font-size:11px;font-weight:600;color:var(--gray-400);letter-spacing:.12em;
text-transform:uppercase;margin:18px 0 10px;display:flex;align-items:center;gap:14px;}
.section-label::after{content:'';flex:1;height:1px;background:var(--gray-200);}
.by-user{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;margin-bottom:18px;}
.by-user-card{background:#fff;border:1px solid var(--gray-100);border-radius:10px;padding:12px 16px;
box-shadow:var(--shadow);cursor:pointer;transition:all .15s;border-left:4px solid var(--gold);}
.by-user-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(17,36,70,.08);border-color:var(--navy-l);}
.by-user-card.selected{border-left-color:var(--navy);background:#FFFBEB;}
.by-user-name{font-family:'DM Serif Display',serif;font-size:16px;color:var(--navy);line-height:1.2;}
.by-user-total{font-size:20px;font-weight:700;color:var(--gray-900);margin-top:4px;}
.by-user-entries{font-size:11px;color:var(--gray-400);margin-top:2px;}
.tbl-card{background:#fff;border:1px solid var(--gray-100);border-radius:10px;
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:top;}
tr:last-child td{border-bottom:none;}
tr:hover td{background:#F9FAFC;}
tr a.tramite-link{color:var(--navy);font-weight:600;}
tr a.tramite-link:hover{color:var(--gold-d);text-decoration:underline;}
.dept-tag{display:inline-block;padding:1px 7px;border-radius:10px;font-size:9.5px;font-weight:700;
text-transform:uppercase;letter-spacing:.06em;color:#fff;margin-left:4px;}
.dept-adm{background:#6c5b95;} .dept-corp{background:#9E7C2E;}
.dept-mig{background:#2980b9;} .dept-lit{background:#722f37;}
.hrs-cell{font-family:'DM Serif Display',serif;font-size:14px;color:var(--navy);font-variant-numeric:tabular-nums;white-space:nowrap;}
.user-cell{font-weight:500;color:var(--gray-900);}
.desc-cell{color:var(--gray-700);max-width:300px;}
.fecha-cell{font-size:11px;color:var(--gray-400);white-space:nowrap;}
.empty{text-align:center;padding:60px 20px;color:var(--gray-400);font-size:13px;}
.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;}
</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">Reporte de Horas</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-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>
</div>
</header>
<div class="page">
<div class="intro">
<h1>Reporte de Horas</h1>
<p id="intro-text">…</p>
</div>
<div class="toolbar">
<div class="tb-group">
<label>Desde</label>
<input type="date" id="f-from">
</div>
<div class="tb-group">
<label>Hasta</label>
<input type="date" id="f-to">
</div>
<div class="tb-group" id="user-filter-wrap" style="display:none;">
<label>Usuario</label>
<select id="f-user">
<option value="0">— Todos —</option>
</select>
</div>
<div class="tb-group">
<label>&nbsp;</label>
<div class="tb-quick">
<button class="quick-btn" onclick="setQuick('today')">Hoy</button>
<button class="quick-btn" onclick="setQuick('week')">Esta semana</button>
<button class="quick-btn" onclick="setQuick('month')">Este mes</button>
<button class="quick-btn" onclick="setQuick('year')">Este año</button>
<button class="quick-btn" onclick="setQuick('all')">Todo</button>
</div>
</div>
<div class="tb-spacer"></div>
<button class="btn-primary" onclick="loadAll()">Aplicar</button>
</div>
<div class="kpi-strip">
<div class="kpi"><div class="kpi-label">Total horas</div><div class="kpi-val" id="k-total">—</div><div class="kpi-sub" id="k-total-sub">—</div></div>
<div class="kpi"><div class="kpi-label">Entradas</div><div class="kpi-val" id="k-entries">—</div><div class="kpi-sub">registros de horas</div></div>
<div class="kpi" id="kpi-users" style="display:none;"><div class="kpi-label">Personas con horas</div><div class="kpi-val" id="k-users">—</div><div class="kpi-sub">en el período</div></div>
</div>
<div id="by-user-section" style="display:none;">
<div class="section-label">Por persona</div>
<div class="by-user" id="by-user"></div>
</div>
<div class="section-label">Detalle de entradas <span id="detail-count" style="margin-left:auto;font-size:11px;color:var(--gray-400);"></span></div>
<div class="tbl-card">
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Trámite</th>
<th>Cliente</th>
<th id="th-abogado">Abogado</th>
<th>Horas</th>
<th>Descripción</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" style="display:none;">Sin entradas de horas en el período seleccionado.</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const MY_EMAIL = (window.DONNA && window.DONNA.me) || '';
const MY_NAME = (window.DONNA && window.DONNA.name) || '';
const IS_SOCIO = !!(window.DONNA && window.DONNA.isSocio);
let data = null;
let authUser = { email: MY_EMAIL, name: MY_NAME };
const DEPT_LABEL = { adm:'Adm', corp:'Corp', mig:'Mig', lit:'Lit' };
function $(id){ return document.getElementById(id); }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function toast(msg, type='ok'){
const el=$('toast'); el.textContent=msg;
el.className='toast show'+(type==='error'?' error':'');
setTimeout(()=>el.className='toast'+(type==='error'?' error':''),3000);
}
function initials(text){
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
return ((parts[0]||'').charAt(0)+(parts[1]||'').charAt(0)).toUpperCase()||'?';
}
function fmtHours(decimal){
const n = Math.round((parseFloat(decimal)||0) * 4) / 4;
const h = Math.floor(n);
const m = Math.round((n - h) * 60);
return m === 0 ? String(h) : `${h}:${String(m).padStart(2,'0')}`;
}
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:'2-digit',year:'numeric'})
+' '+d.toLocaleTimeString('es-PA',{hour:'2-digit',minute:'2-digit'});
}
function ymd(d){
const y=d.getFullYear(), m=String(d.getMonth()+1).padStart(2,'0'), dd=String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${dd}`;
}
function setUserChip(){
$('user-avatar').textContent = initials(authUser.name || authUser.email);
$('user-name').textContent = authUser.name || authUser.email || '—';
$('user-name').title = authUser.email;
}
if (IS_SOCIO) {
document.addEventListener('DOMContentLoaded', () => {
const g = $('btn-gear');
if (g) g.style.display = 'inline-flex';
});
}
function defaultDates(){
const now = new Date();
const first = new Date(now.getFullYear(), now.getMonth(), 1);
$('f-from').value = ymd(first);
$('f-to').value = ymd(now);
}
function setQuick(period){
const now = new Date();
let from, to;
if (period === 'today') { from = now; to = now; }
if (period === 'week') { const d = now.getDay() || 7; from = new Date(now); from.setDate(now.getDate() - d + 1); to = now; }
if (period === 'month') { from = new Date(now.getFullYear(), now.getMonth(), 1); to = now; }
if (period === 'year') { from = new Date(now.getFullYear(), 0, 1); to = now; }
if (period === 'all') { $('f-from').value = ''; $('f-to').value = ''; loadAll(); return; }
$('f-from').value = ymd(from);
$('f-to').value = ymd(to);
loadAll();
}
async function apiCall(qs){
const url = '/horas/api.php?action=list' + (qs?'&'+qs:'');
const res = await fetch(url, {headers:{Accept:'application/json'}, credentials:'same-origin'});
let j;
try{ j = await res.json(); } catch(e){ throw new Error('JSON inválido'); }
if(res.status===401 && j.auth_required){
window.location.href = j.login_url || '/auth/login.php?return_to=/horas/';
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 loadAll(){
const from = $('f-from').value;
const to = $('f-to').value;
const user = IS_SOCIO ? ($('f-user') ? $('f-user').value : '0') : '0';
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
if (user && user !== '0') params.set('user', user);
try {
data = await apiCall(params.toString());
if (!data) return;
setUserChip();
render();
} catch(e){
toast('Error: '+e.message, 'error');
}
}
function populateUserFilter(){
if (!data || !data.is_socio) return;
const wrap = $('user-filter-wrap');
const sel = $('f-user');
wrap.style.display = '';
const prev = sel.value;
sel.innerHTML = '<option value="0">— Todos —</option>' +
(data.avail_users||[]).map(u => `<option value="${u.id}">${esc(u.name)}</option>`).join('');
sel.value = prev;
}
function render(){
if (!data) return;
populateUserFilter();
const introText = data.is_socio
? 'Visualiza las horas registradas por todos los abogados y usuarios. Filtra por rango y por persona.'
: ('Estas son tus horas registradas (donde figuras como ' + esc(data.my_name||'—') + ' en el registro de horas).');
$('intro-text').textContent = data.note || introText;
$('k-total').textContent = fmtHours(data.total_hrs || 0);
$('k-total-sub').textContent = (data.total_hrs || 0).toFixed(2) + ' h decimal';
$('k-entries').textContent = (data.entries||[]).length;
if (data.is_socio) {
$('kpi-users').style.display = '';
$('k-users').textContent = (data.by_user||[]).length;
}
const sectByUser = $('by-user-section');
if (data.is_socio && (data.by_user||[]).length) {
sectByUser.style.display = '';
const selectedUserId = $('f-user') ? $('f-user').value : '0';
const selectedName = selectedUserId === '0'
? null
: (data.avail_users.find(u => String(u.id) === selectedUserId) || {}).name;
$('by-user').innerHTML = data.by_user.map(bu => {
const u = (data.avail_users||[]).find(x => x.name === bu.name);
const sel = selectedName && bu.name === selectedName;
const onclick = u ? `onclick="selectUser(${u.id})"` : '';
return `<div class="by-user-card ${sel?'selected':''}" ${onclick}>
<div class="by-user-name">${esc(bu.name)}</div>
<div class="by-user-total">${fmtHours(bu.total)} h</div>
<div class="by-user-entries">${bu.entries} ${bu.entries===1?'entrada':'entradas'}</div>
</div>`;
}).join('');
} else {
sectByUser.style.display = 'none';
}
$('detail-count').textContent = (data.entries||[]).length + ' ' + ((data.entries||[]).length === 1 ? 'entrada' : 'entradas');
$('th-abogado').style.display = data.is_socio ? '' : 'none';
const tbody = $('tbody');
if (!(data.entries||[]).length) {
tbody.innerHTML = '';
$('empty').style.display = 'block';
return;
}
$('empty').style.display = 'none';
tbody.innerHTML = data.entries.map(e => {
const dept = e.tramite_departamento;
const tramiteLink = e.tramite_codigo
? `<a class="tramite-link" href="/tramites/?edit=${encodeURIComponent(e.tramite_codigo)}" title="Abrir en Trámites">${esc(e.tramite_codigo)}</a>`
: '—';
const abogadoCol = data.is_socio
? `<td class="user-cell">${esc(e.abogado||'—')}</td>`
: '';
return `<tr>
<td class="fecha-cell">${esc(fmtDate(e.fecha))}</td>
<td>${tramiteLink} <span class="dept-tag dept-${dept}">${DEPT_LABEL[dept]||dept}</span><br><small style="color:var(--gray-400)">${esc(e.tramite_title||'—')}</small></td>
<td>${esc(e.tramite_cliente||'—')}</td>
${abogadoCol}
<td class="hrs-cell">${fmtHours(e.hrs)}</td>
<td class="desc-cell">${esc(e.descripcion||'—')}</td>
</tr>`;
}).join('');
}
function selectUser(id){
const sel = $('f-user');
if (sel) { sel.value = String(id); loadAll(); }
}
['f-from','f-to'].forEach(id => {
$(id).addEventListener('change', loadAll);
});
$('f-user') && $('f-user').addEventListener('change', loadAll);
defaultDates();
loadAll();
</script>
</body>
</html>
+6 -3
View File
@@ -33,9 +33,9 @@ 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;}
.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-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;}
.btn-gear,.btn-clock{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,.btn-clock:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.btn-gear svg,.btn-clock 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;}
@@ -161,6 +161,9 @@ tr.expanded>td{background:#fffdf5;}
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">Cargando…</span>
</div>
<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>
<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>