Files
donna/views/horas.html
T
Soporte ZH Consultores 3f922bc877 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
2026-06-04 22:10:05 -05:00

473 lines
22 KiB
HTML

<!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(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);}
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-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-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>
<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;
}
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 = '';
$('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';
}
$('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(); }
}
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);
});
$('f-user') && $('f-user').addEventListener('change', loadAll);
defaultDates();
loadAll();
</script>
</body>
</html>