feat(horas): desglose por trámite en cada abogado + exportar a Excel

- API: by_user_tramite agrupa horas por (abogado, trámite) para
  socios y para el propio abogado (no-socio)
- API: nueva acción ?action=export que devuelve CSV con BOM UTF-8
  (Fecha · Abogado · Departamento · Código · Trámite · Cliente ·
  Estatus · Horas · Descripción), respetando los mismos filtros
- UI: tarjetas de "Por persona" ahora son expandibles (clic en
  la tarjeta) y muestran un mini-listado de trámites con total
  de horas y cantidad de entradas por trámite
- UI: cada trámite del desglose es clickable y abre la ficha
- UI: botón verde "Exportar a Excel" en la barra de herramientas
This commit is contained in:
Soporte ZH Consultores
2026-06-04 22:10:05 -05:00
parent 8ff56d3d78
commit 3f922bc877
2 changed files with 171 additions and 27 deletions
+82 -2
View File
@@ -19,7 +19,7 @@ $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$pdo = Db::pdo();
if ($method !== 'GET' || $action !== 'list') err('Acción no reconocida', 404);
if ($method !== 'GET' || !in_array($action, ['list','export'], true)) err('Acción no reconocida', 404);
try {
$from = clean_date($_GET['from'] ?? null);
@@ -94,14 +94,17 @@ try {
// 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 h.abogado AS name, SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h
WHERE " . implode(' AND ', $byWhere) . "
WHERE $whereByUser
GROUP BY h.abogado
ORDER BY total DESC";
$byStmt = $pdo->prepare($bySql);
@@ -112,6 +115,52 @@ try {
$bu['entries'] = (int)$bu['entries'];
}
unset($bu);
// Breakdown por usuario × trámite
$butSql = "SELECT 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
WHERE $whereByUser
GROUP BY h.abogado, t.id
ORDER BY h.abogado 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 ($filterName !== null) {
// Abogado/usuario regular: su propio desglose por trámite
$butParams = [':name' => $filterName];
$butWhere = ['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 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
WHERE " . implode(' AND ', $butWhere) . "
GROUP BY 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)
@@ -126,9 +175,40 @@ try {
unset($u);
}
if ($action === 'export') {
// CSV con BOM UTF-8 para que Excel lea acentos correctamente
$filename = 'horas_' . date('Y-m-d_His') . '.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$deptLabels = ['adm'=>'Administrativo', 'corp'=>'Corp · Com · Civil', 'mig'=>'Migración', 'lit'=>'Litigios'];
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Fecha', 'Abogado', 'Departamento', 'Código', 'Trámite', 'Cliente', 'Estatus', 'Horas', 'Descripción']);
foreach ($entries as $e) {
$hrs = (float)$e['hrs'];
$full = floor($hrs);
$mins = (int)round(($hrs - $full) * 60);
$hrsFmt = $mins === 0 ? (string)$full : sprintf('%d:%02d', $full, $mins);
fputcsv($out, [
date('Y-m-d H:i', strtotime($e['fecha'])),
$e['abogado'],
$deptLabels[$e['tramite_departamento']] ?? $e['tramite_departamento'],
$e['tramite_codigo'],
$e['tramite_title'],
$e['tramite_cliente'],
$e['tramite_estatus'],
$hrsFmt,
$e['descripcion'],
]);
}
fclose($out);
exit;
}
ok([
'entries' => $entries,
'by_user' => $byUser,
'by_user_tramite' => $byUserTramite,
'avail_users' => $availUsers,
'total_hrs' => $totalHrs,
'is_socio' => Auth::isSocio(),
+83 -19
View File
@@ -85,14 +85,35 @@ header{background:var(--navy);border-bottom:3px solid var(--gold);padding:0 24px
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{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;margin-bottom:18px;align-items:start;}
.by-user-card{background:#fff;border:1px solid var(--gray-100);border-radius:10px;
box-shadow:var(--shadow);transition:all .15s;border-left:4px solid var(--gold);overflow:hidden;}
.by-user-card.expanded{border-left-color:var(--navy);background:#FFFBEB;}
.by-user-head{padding:12px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;transition:background .15s;}
.by-user-head:hover{background:#F9FAFC;}
.by-user-card.expanded .by-user-head{background:#FFFBEB;}
.by-user-info{flex:1;min-width:0;}
.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;}
.by-user-chev{width:14px;height:14px;color:var(--gray-400);transition:transform .15s;flex-shrink:0;}
.by-user-card.expanded .by-user-chev{transform:rotate(90deg);color:var(--navy);}
.by-user-tramites{display:none;border-top:1px solid var(--gray-100);background:#fff;}
.by-user-card.expanded .by-user-tramites{display:block;}
.but-row{display:grid;grid-template-columns:1fr auto;gap:8px;padding:9px 16px;border-bottom:1px solid var(--gray-100);
font-size:12px;cursor:pointer;transition:background .12s;align-items:center;}
.but-row:hover{background:#F9FAFC;}
.but-row:last-child{border-bottom:none;}
.but-tcode{font-weight:600;color:var(--navy);font-size:11.5px;}
.but-ttitle{color:var(--gray-700);font-size:11.5px;margin-top:1px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;}
.but-tcli{font-size:10.5px;color:var(--gray-400);margin-top:1px;}
.but-hrs{font-family:'DM Serif Display',serif;font-size:14px;color:var(--navy);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;}
.but-n{font-size:9.5px;color:var(--gray-400);text-align:right;}
.but-empty{padding:14px 16px;font-size:11.5px;color:var(--gray-400);text-align:center;}
.btn-export{padding:8px 14px;background:#1e7e3e;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;display:inline-flex;align-items:center;gap:6px;}
.btn-export:hover{background:#15692f;}
.btn-export svg{width:14px;height:14px;}
.tbl-card{background:#fff;border:1px solid var(--gray-100);border-radius:10px;
overflow:hidden;box-shadow:var(--shadow);}
@@ -182,6 +203,10 @@ tr a.tramite-link:hover{color:var(--gold-d);text-decoration:underline;}
</div>
</div>
<div class="tb-spacer"></div>
<button class="btn-export" onclick="exportExcel()" title="Descargar CSV (compatible con Excel)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar a Excel
</button>
<button class="btn-primary" onclick="loadAll()">Aplicar</button>
</div>
@@ -345,20 +370,13 @@ function render(){
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('');
$('by-user').innerHTML = data.by_user.map(bu => byUserCardHtml(bu, bu.name)).join('');
} else if (!data.is_socio && (data.by_user_tramite||[]).length) {
// Abogado/usuario regular: muestra su propia tarjeta colapsable
sectByUser.style.display = '';
const myTotal = (data.by_user_tramite||[]).reduce((a,r)=>a+(+r.total||0),0);
const myEntries = (data.by_user_tramite||[]).reduce((a,r)=>a+(+r.entries||0),0);
$('by-user').innerHTML = byUserCardHtml({name:data.my_name, total:myTotal, entries:myEntries}, data.my_name);
} else {
sectByUser.style.display = 'none';
}
@@ -396,6 +414,52 @@ function selectUser(id){
if (sel) { sel.value = String(id); loadAll(); }
}
function byUserCardHtml(bu, abogadoName){
const tramites = ((data && data.by_user_tramite)||[]).filter(r => r.name === abogadoName);
const safeName = (abogadoName||'').replace(/[^a-zA-Z0-9_]/g,'_');
const trHtml = tramites.length
? tramites.map(t => `
<div class="but-row" onclick="event.stopPropagation();openTramite('${esc(t.tramite_codigo)}')">
<div>
<div class="but-tcode">${esc(t.tramite_codigo)} <span class="dept-tag dept-${t.departamento}">${DEPT_LABEL[t.departamento]||t.departamento}</span></div>
<div class="but-ttitle">${esc(t.tramite_title||'—')}</div>
<div class="but-tcli">${esc(t.tramite_cliente||'—')}</div>
</div>
<div>
<div class="but-hrs">${fmtHours(t.total)} h</div>
<div class="but-n">${t.entries} ${t.entries===1?'entrada':'entradas'}</div>
</div>
</div>`).join('')
: `<div class="but-empty">Sin desglose por trámite en este período.</div>`;
return `<div class="by-user-card" id="buc-${safeName}">
<div class="by-user-head" onclick="toggleByUser('${safeName}')">
<div class="by-user-info">
<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'} · ${tramites.length} ${tramites.length===1?'trámite':'trámites'}</div>
</div>
<svg class="by-user-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="by-user-tramites">${trHtml}</div>
</div>`;
}
function toggleByUser(safeName){
const card = $('buc-'+safeName);
if (card) card.classList.toggle('expanded');
}
function openTramite(codigo){
window.location.href = '/tramites/?edit=' + encodeURIComponent(codigo);
}
function exportExcel(){
const params = new URLSearchParams({
action: 'export',
from: $('f-from').value || '',
to: $('f-to').value || '',
user: ($('f-user') && $('f-user').value) || '0',
});
window.location.href = '/horas/api.php?' + params.toString();
}
['f-from','f-to'].forEach(id => {
$(id).addEventListener('change', loadAll);
});