Files
donna/public/horas/api.php
T
Soporte ZH Consultores 0a8dc6fc46 refactor(hours): FK user_id en tramites_hours y tramites_obs
Hasta ahora el vínculo "esta hora/observación es de X" era por string
(h.abogado = users.name). Se rompía cuando un Socio renombraba un
usuario en el Panel de Control (las entradas históricas conservaban
el nombre viejo y los filtros del Reporte de Horas no las hallaban).

- Migración 014: ADD COLUMN user_id INT NULL + FK ON DELETE SET NULL
  en ambas tablas, con backfill via JOIN users.name = abogado
- tramites/api.php: helper userIdByName() + add_obs/add_hours/
  update_hours ahora persisten user_id junto con el string abogado
- horas/api.php: filtro WHERE (user_id = :uid OR (user_id IS NULL
  AND abogado = :name)) — prioriza FK, cae al string solo para
  filas legacy sin user_id. by_user y by_user_tramite agrupan por
  COALESCE(u.name, h.abogado) para usar siempre el nombre actual.
2026-06-04 22:33:41 -05:00

211 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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é usuario filtrar:
// - Socio/admin: por user seleccionado (id+nombre), o todos si user=0
// - Resto: forzado a su propio user_id (con fallback a su name para legacy)
$filterUserId = null;
$filterName = null;
if (Auth::isSocio()) {
if ($userParam > 0) {
$sel = $pdo->prepare('SELECT id, name FROM users WHERE id = :id');
$sel->execute([':id' => $userParam]);
$u = $sel->fetch();
if ($u) { $filterUserId = (int)$u['id']; $filterName = (string)$u['name']; }
}
} else {
$filterUserId = Auth::userId();
$filterName = Auth::userName();
if ($filterUserId <= 0 && $filterName === '') {
ok([
'entries' => [],
'by_user' => [],
'by_user_tramite' => [],
'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.',
]);
}
}
// Filtro por usuario: prefiere user_id (canónico); cae al name solo
// para filas legacy donde user_id quedó NULL.
$where = ['1=1'];
$params = [];
if ($filterUserId !== null && $filterUserId > 0) {
$where[] = '(h.user_id = :uid OR (h.user_id IS NULL AND h.abogado = :name))';
$params[':uid'] = $filterUserId;
$params[':name'] = (string)$filterName;
} elseif ($filterName !== null && $filterName !== '') {
// Fallback: solo nombre disponible (no debería ocurrir tras 014)
$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 = [];
$byUserTramite = [];
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'; }
$whereByUser = implode(' AND ', $byWhere);
$bySql = "SELECT COALESCE(u.name, h.abogado) AS name,
SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h
LEFT JOIN users u ON u.id = h.user_id
WHERE $whereByUser
GROUP BY COALESCE(u.name, 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);
// Breakdown por usuario × trámite
$butSql = "SELECT COALESCE(u.name, h.abogado) AS name,
t.id AS tramite_id, t.codigo AS tramite_codigo,
t.departamento AS departamento,
COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title,
t.cliente AS tramite_cliente,
SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h
JOIN tramites_legales t ON t.id = h.tramite_id
LEFT JOIN users u ON u.id = h.user_id
WHERE $whereByUser
GROUP BY COALESCE(u.name, h.abogado), t.id
ORDER BY name ASC, total DESC";
$butStmt = $pdo->prepare($butSql);
$butStmt->execute($byParams);
$byUserTramite = $butStmt->fetchAll();
foreach ($byUserTramite as &$r) {
$r['tramite_id'] = (int)$r['tramite_id'];
$r['total'] = (float)$r['total'];
$r['entries'] = (int)$r['entries'];
}
unset($r);
} elseif ($filterUserId !== null && $filterUserId > 0) {
// Abogado/usuario regular: su propio desglose por trámite (vía user_id + fallback name)
$butParams = [':uid' => $filterUserId, ':name' => (string)$filterName];
$butWhere = ['(h.user_id = :uid OR (h.user_id IS NULL AND h.abogado = :name))'];
if ($from) { $butWhere[] = 'h.fecha >= :from'; $butParams[':from'] = $from . ' 00:00:00'; }
if ($to) { $butWhere[] = 'h.fecha <= :to'; $butParams[':to'] = $to . ' 23:59:59'; }
$butSql = "SELECT COALESCE(u.name, h.abogado) AS name,
t.id AS tramite_id, t.codigo AS tramite_codigo,
t.departamento AS departamento,
COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title,
t.cliente AS tramite_cliente,
SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h
JOIN tramites_legales t ON t.id = h.tramite_id
LEFT JOIN users u ON u.id = h.user_id
WHERE " . implode(' AND ', $butWhere) . "
GROUP BY COALESCE(u.name, h.abogado), t.id
ORDER BY total DESC";
$butStmt = $pdo->prepare($butSql);
$butStmt->execute($butParams);
$byUserTramite = $butStmt->fetchAll();
foreach ($byUserTramite as &$r) {
$r['tramite_id'] = (int)$r['tramite_id'];
$r['total'] = (float)$r['total'];
$r['entries'] = (int)$r['entries'];
}
unset($r);
}
// 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,
'by_user_tramite' => $byUserTramite,
'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);
}