feat(tramites): Horas Incurridas en bloques de 15 min con formato h:mm

* Input de horas pasa de <input type=number step=0.1> a un <select> con
  48 opciones de 0:15 a 12:00 en bloques de 15 minutos. Valor enviado
  al backend sigue siendo decimal (0.25 = 15 min, 1.5 = 1h30m).
* fmtHours() formatea decimal → "h" para horas enteras o "h:mm" para
  fraccionarias. Snap a 15 min para tolerar datos legacy.
* Display unificado:
  - Columna "Horas" en la tabla: muestra total en formato h:mm
  - Lista de entradas en detalle: cada entrada con su tiempo formateado
  - "Total" del card de Horas: en formato h:mm
* Aplica a los 4 departamentos.
This commit is contained in:
Soporte ZH Consultores
2026-06-05 00:45:15 +00:00
parent a01d1b29d0
commit bbf91c7bec
+27 -9
View File
@@ -438,9 +438,27 @@ function statusSlug(s){return (s||'').toLowerCase().replace(/\s+/g,'').replace(/
function statusBadge(s){return `<span class="badge b-${statusSlug(s)}">${esc(s)}</span>`;} function statusBadge(s){return `<span class="badge b-${statusSlug(s)}">${esc(s)}</span>`;}
function yesnoBadge(v){return v?`<span class="badge ${v==='Sí'?'b-si':'b-no'}">${esc(v)}</span>`:'—';} function yesnoBadge(v){return v?`<span class="badge ${v==='Sí'?'b-si':'b-no'}">${esc(v)}</span>`:'—';}
// Formatea decimal de horas como "h" o "h:mm" (bloques de 15 min).
// Ej: 0.25 → "0:15", 1 → "1", 1.5 → "1:30", 2.75 → "2:45"
function fmtHours(decimal){
const n = Math.round((parseFloat(decimal)||0) * 4) / 4; // snap a 15 min
const h = Math.floor(n);
const m = Math.round((n - h) * 60);
return m === 0 ? String(h) : `${h}:${String(m).padStart(2,'0')}`;
}
function totalHours(r){ function totalHours(r){
if(!r.hours||!r.hours.length) return 0; if(!r.hours||!r.hours.length) return '0';
return r.hours.reduce((s,h)=>s+(parseFloat(h.hrs)||0),0).toFixed(1); const total = r.hours.reduce((s,h)=>s+(parseFloat(h.hrs)||0),0);
return fmtHours(total);
}
// Opciones del dropdown de horas: bloques de 15 min de 0:15 a 12:00
function hourBlockOptions(){
let out = '';
for (let q = 1; q <= 48; q++) {
const value = q * 0.25;
out += `<option value="${value}">${fmtHours(value)}</option>`;
}
return out;
} }
function venceAlert(fecha){ function venceAlert(fecha){
@@ -454,13 +472,13 @@ function venceAlert(fecha){
function buildRow(dept,r){ function buildRow(dept,r){
let cells=''; let cells='';
const invCell = `<td>${involucradosCell(r.involucrados)}</td>`; const invCell = `<td>${involucradosCell(r.involucrados)}</td>`;
if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`; if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)}</td>`;
if(dept==='corp') cells=`<td>${esc(r.tipoDocumento)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`; if(dept==='corp') cells=`<td>${esc(r.tipoDocumento)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)}</td>`;
if(dept==='mig') cells=`<td>${esc(r.tipoTramite)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.permisoTrabajo)}</td><td>${yesnoBadge(r.dependientes)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaVencimiento)||'—'}${venceAlert(r.fechaVencimiento)}</td><td>${totalHours(r)} h</td>`; if(dept==='mig') cells=`<td>${esc(r.tipoTramite)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.permisoTrabajo)}</td><td>${yesnoBadge(r.dependientes)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaVencimiento)||'—'}${venceAlert(r.fechaVencimiento)}</td><td>${totalHours(r)}</td>`;
if(dept==='lit'){ if(dept==='lit'){
const esfera=r.esfera||''; const esfera=r.esfera||'';
const inst=r.instancia||''; const inst=r.instancia||'';
cells=`<td><b>${esc(r.tipoProceso)||'—'}</b><br><small>${esc(r.juzgado)||'—'} · ${esc(r.circuito)||'—'}</small></td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${esfera?`<span class="badge b-${esfera.toLowerCase()}">${esfera}</span>`:'—'}</td><td>${inst?`<span class="badge b-${statusSlug(inst)}">${inst}</span>`:'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.incidentes)}</td><td>${yesnoBadge(r.amparo)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaTermino)||'—'}${venceAlert(r.fechaTermino)}</td><td>${totalHours(r)} h</td>`; cells=`<td><b>${esc(r.tipoProceso)||'—'}</b><br><small>${esc(r.juzgado)||'—'} · ${esc(r.circuito)||'—'}</small></td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${esfera?`<span class="badge b-${esfera.toLowerCase()}">${esfera}</span>`:'—'}</td><td>${inst?`<span class="badge b-${statusSlug(inst)}">${inst}</span>`:'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.incidentes)}</td><td>${yesnoBadge(r.amparo)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaTermino)||'—'}${venceAlert(r.fechaTermino)}</td><td>${totalHours(r)}</td>`;
} }
const delBtn = CAN_DELETE ? `<button class="btn btn-danger btn-sm" onclick="deleteRow('${dept}','${r.id}');event.stopPropagation()">✕</button>` : ''; const delBtn = CAN_DELETE ? `<button class="btn btn-danger btn-sm" onclick="deleteRow('${dept}','${r.id}');event.stopPropagation()">✕</button>` : '';
const actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> ${delBtn}</td>`; const actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> ${delBtn}</td>`;
@@ -495,7 +513,7 @@ function buildDetail(dept,r){
const obs=(r.obs||[]).map(o=>`<li>${esc(o.texto)}<br><span class="obs-meta">${esc(o.abogado)} · ${esc(o.fecha)}${o.autor_email?` · <i>${esc(o.autor_email.split('@')[0])}</i>`:''}</span></li>`).join(''); const obs=(r.obs||[]).map(o=>`<li>${esc(o.texto)}<br><span class="obs-meta">${esc(o.abogado)} · ${esc(o.fecha)}${o.autor_email?` · <i>${esc(o.autor_email.split('@')[0])}</i>`:''}</span></li>`).join('');
const lastUpd=r.obs&&r.obs.length?`<div class="last-upd">Última actualización: ${esc(r.obs[r.obs.length-1].fecha)}</div>`:''; const lastUpd=r.obs&&r.obs.length?`<div class="last-upd">Última actualización: ${esc(r.obs[r.obs.length-1].fecha)}</div>`:'';
const hrs=(r.hours||[]).map(h=>`<li><span class="hl-who">${esc(h.abogado)}</span><span>${h.hrs} h · ${esc(h.fecha)}</span></li>`).join(''); const hrs=(r.hours||[]).map(h=>`<li><span class="hl-who">${esc(h.abogado)}</span><span>${fmtHours(h.hrs)} · ${esc(h.fecha)}</span></li>`).join('');
const totalH=totalHours(r); const totalH=totalHours(r);
const statusSel=`<select onchange="changeStatus('${dept}','${r.id}',this.value)" onclick="event.stopPropagation()"> const statusSel=`<select onchange="changeStatus('${dept}','${r.id}',this.value)" onclick="event.stopPropagation()">
@@ -528,7 +546,7 @@ function buildDetail(dept,r){
<div class="detail-card"> <div class="detail-card">
<h4>Horas Incurridas</h4> <h4>Horas Incurridas</h4>
${hrs?`<ul class="hours-list">${hrs}</ul>`:'<p style="color:var(--gray);font-size:11px">Sin horas registradas.</p>'} ${hrs?`<ul class="hours-list">${hrs}</ul>`:'<p style="color:var(--gray);font-size:11px">Sin horas registradas.</p>'}
<div class="total-hrs">Total: ${totalH} h</div> <div class="total-hrs">Total: ${totalH}</div>
${(()=>{ ${(()=>{
// Opciones del dropdown de Horas: abogado solicitante + todos los involucrados, // Opciones del dropdown de Horas: abogado solicitante + todos los involucrados,
// deduplicados por nombre. Si el trámite no tiene ni solicitante ni involucrados, // deduplicados por nombre. Si el trámite no tiene ni solicitante ni involucrados,
@@ -542,7 +560,7 @@ function buildDetail(dept,r){
${enabled ${enabled
? `<select id="hr-abog-${r.id}">${list.map(n=>`<option>${esc(n)}</option>`).join('')}</select>` ? `<select id="hr-abog-${r.id}">${list.map(n=>`<option>${esc(n)}</option>`).join('')}</select>`
: `<select id="hr-abog-${r.id}" disabled title="Agrega un abogado solicitante o involucrado primero"><option>— Sin asignados —</option></select>`} : `<select id="hr-abog-${r.id}" disabled title="Agrega un abogado solicitante o involucrado primero"><option>— Sin asignados —</option></select>`}
<input type="number" id="hr-val-${r.id}" min="0.1" step="0.1" placeholder="Horas" style="width:70px" ${enabled?'':'disabled'}> <select id="hr-val-${r.id}" style="width:80px" ${enabled?'':'disabled'} title="Tiempo en bloques de 15 min"><option value="">Horas</option>${hourBlockOptions()}</select>
<input type="text" id="hr-desc-${r.id}" placeholder="Descripción" style="flex:1" ${enabled?'':'disabled'}> <input type="text" id="hr-desc-${r.id}" placeholder="Descripción" style="flex:1" ${enabled?'':'disabled'}>
<button class="btn btn-primary btn-sm" onclick="addHours('${dept}','${r.id}')" ${enabled?'':'disabled'} title="${enabled?'Registrar horas':'Agrega un abogado solicitante o involucrado primero'}">+</button> <button class="btn btn-primary btn-sm" onclick="addHours('${dept}','${r.id}')" ${enabled?'':'disabled'} title="${enabled?'Registrar horas':'Agrega un abogado solicitante o involucrado primero'}">+</button>
</div>`; </div>`;