diff --git a/public/horas/api.php b/public/horas/api.php index c26dab9..ad2f061 100644 --- a/public/horas/api.php +++ b/public/horas/api.php @@ -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,13 +175,44 @@ 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, - 'avail_users' => $availUsers, - 'total_hrs' => $totalHrs, - 'is_socio' => Auth::isSocio(), - 'my_name' => Auth::userName(), + '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) { diff --git a/views/horas.html b/views/horas.html index 62cd70b..c40bee9 100644 --- a/views/horas.html +++ b/views/horas.html @@ -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;}
+ @@ -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 `
-
${esc(bu.name)}
-
${fmtHours(bu.total)} h
-
${bu.entries} ${bu.entries===1?'entrada':'entradas'}
-
`; - }).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 => ` +
+
+
${esc(t.tramite_codigo)} ${DEPT_LABEL[t.departamento]||t.departamento}
+
${esc(t.tramite_title||'—')}
+
${esc(t.tramite_cliente||'—')}
+
+
+
${fmtHours(t.total)} h
+
${t.entries} ${t.entries===1?'entrada':'entradas'}
+
+
`).join('') + : `
Sin desglose por trámite en este período.
`; + return `
+
+ + +
+
${trHtml}
+
`; +} +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); });