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:
+82
-2
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user