fix(tz): servidor en America/Panama + edit/delete de horas registradas

TIMEZONE
* OS: timedatectl set-timezone America/Panama (era UTC). El reporte
  de horas salía vacío para entries creados después de las 19:00 hora
  Panama porque el filtro "este mes" del frontend usa local time pero
  los datos se guardaban en UTC del día siguiente.
* MariaDB y PHP ahora corren en hora Panamá:
  - src/Db.php hace SET time_zone='-05:00' por conexión.
  - src/Config.php hace date_default_timezone_set('America/Panama').
* Migración 009: convierte timestamps existentes UTC → Panama usando
  CONVERT_TZ en todas las tablas con DATETIME (users, escrituras_*,
  tramites_*).

EDIT/DELETE DE HORAS
* Backend tramites/api.php: nuevos endpoints
  - PUT  ?action=update_hours: cambia abogado, hrs y descripción
  - DELETE ?action=delete_hours
  Permisos: solo el autor_email original (quien registró) o un
  Socio/Admin puede editar/eliminar. También se valida canView().
* Frontend tramites.html: cada entrada de horas ahora muestra botones
  ✎ (editar) y ✕ (eliminar) al lado de la fecha, visibles solo para
  quien tiene permiso. Editar abre un modal pequeño dedicado con los
  mismos campos (abogado dropdown, horas dropdown 15-min, descripción).
This commit is contained in:
Soporte ZH Consultores
2026-06-04 20:10:49 -05:00
parent 72c04ad594
commit e98d791cb8
5 changed files with 203 additions and 4 deletions
+107 -4
View File
@@ -103,8 +103,13 @@ tr.expanded>td{background:#fffdf5;}
.add-obs{display:flex;gap:6px;margin-top:8px;}
.add-obs textarea{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:5px 8px;resize:vertical;min-height:48px;}
.hours-list{list-style:none;}
.hours-list li{font-size:11.5px;padding:3px 0;display:flex;justify-content:space-between;border-bottom:1px dashed #eee;}
.hours-list li{font-size:11.5px;padding:3px 0;display:flex;justify-content:space-between;align-items:center;gap:8px;border-bottom:1px dashed #eee;}
.hours-list li .hl-who{color:var(--navy);font-weight:600;}
.hours-list li .hl-right{display:flex;align-items:center;gap:8px;}
.hr-actions{display:inline-flex;gap:3px;}
.hr-actions button{background:transparent;border:1px solid var(--border);border-radius:3px;padding:1px 6px;font-size:10px;cursor:pointer;color:var(--gray);line-height:1.3;}
.hr-actions .hr-edit:hover{background:var(--gold);color:var(--navy);border-color:var(--gold);}
.hr-actions .hr-del:hover{background:#e74c3c;color:#fff;border-color:#e74c3c;}
.add-hours{display:flex;gap:6px;margin-top:8px;align-items:center;flex-wrap:wrap;}
.add-hours select,.add-hours input{font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:4px;}
.total-hrs{font-size:11px;font-weight:700;color:var(--navy);margin-top:6px;}
@@ -197,6 +202,21 @@ tr.expanded>td{background:#fffdf5;}
</div>
</div>
<!-- Modal para editar entrada de horas -->
<div class="modal-overlay" id="hr-modal">
<div class="modal" style="max-width:480px;">
<div class="modal-header">
<h3>Editar registro de horas</h3>
<button class="modal-close" onclick="closeHrModal()">✕</button>
</div>
<div class="modal-body" id="hr-modal-body"></div>
<div class="modal-footer">
<button class="btn" onclick="closeHrModal()">Cancelar</button>
<button class="btn btn-primary" onclick="saveHrEdit()">Guardar cambios</button>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
@@ -455,11 +475,13 @@ function totalHours(r){
return fmtHours(total);
}
// Opciones del dropdown de horas: bloques de 15 min de 0:15 a 12:00
function hourBlockOptions(){
function hourBlockOptions(currentValue){
let out = '';
const cur = currentValue !== undefined ? parseFloat(currentValue) : null;
for (let q = 1; q <= 48; q++) {
const value = q * 0.25;
out += `<option value="${value}">${fmtHours(value)}</option>`;
const sel = (cur !== null && Math.abs(value - cur) < 0.001) ? ' selected' : '';
out += `<option value="${value}"${sel}>${fmtHours(value)}</option>`;
}
return out;
}
@@ -516,7 +538,19 @@ 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 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>${fmtHours(h.hrs)} · ${esc(h.fecha)}</span></li>`).join('');
const hrs=(r.hours||[]).map(h => {
const canEditHr = IS_SOCIO || h.autor_email === MY_EMAIL;
const actions = canEditHr
? `<span class="hr-actions" onclick="event.stopPropagation()">
<button class="hr-edit" onclick="editHour(${h.id},'${dept}','${r.id}')" title="Editar">✎</button>
<button class="hr-del" onclick="deleteHour(${h.id},'${dept}','${r.id}')" title="Eliminar">✕</button>
</span>`
: '';
return `<li>
<span class="hl-who">${esc(h.abogado)}</span>
<span class="hl-right"><span>${fmtHours(h.hrs)} · ${esc(h.fecha)}</span>${actions}</span>
</li>`;
}).join('');
const totalH=totalHours(r);
const statusSel=`<select onchange="changeStatus('${dept}','${r.id}',this.value)" onclick="event.stopPropagation()">
@@ -616,6 +650,75 @@ async function addObs(dept,codigo){
} catch(e){ toast('Error: '+e.message,'error'); }
}
// ── EDIT/DELETE HORAS ────────────────────────────────────────
let editingHr = null;
function editHour(hourId, dept, codigo){
const r = findRow(dept, codigo); if(!r) return;
const h = (r.hours||[]).find(x => Number(x.id) === Number(hourId)); if(!h) return;
editingHr = { id: Number(hourId), dept, codigo };
// Opciones de abogado: solicitante + involucrados (mismo set que add-hours)
const opts = new Set();
if (r.abogado) opts.add(r.abogado);
(r.involucrados||[]).forEach(u => { if (u.name||u.email) opts.add(u.name||u.email); });
const optList = [...opts];
document.getElementById('hr-modal-body').innerHTML = `
<div class="form-group">
<label>Abogado</label>
<select id="he-abog">${optList.map(n=>`<option ${n===h.abogado?'selected':''}>${esc(n)}</option>`).join('')}</select>
</div>
<div class="form-group">
<label>Horas</label>
<select id="he-hrs">${hourBlockOptions(h.hrs)}</select>
</div>
<div class="form-group">
<label>Descripción</label>
<input type="text" id="he-desc" value="${esc(h.descripcion||'')}" placeholder="Descripción del trabajo">
</div>
<div style="font-size:10.5px;color:var(--gray);margin-top:6px;">
Registrado: ${esc(h.fecha)} · por ${esc((h.autor_email||'').split('@')[0])}
</div>
`;
document.getElementById('hr-modal').classList.add('open');
}
function closeHrModal(){
document.getElementById('hr-modal').classList.remove('open');
editingHr = null;
}
async function saveHrEdit(){
if (!editingHr) return;
const payload = {
_id: editingHr.id,
abogado: document.getElementById('he-abog').value,
hrs: parseFloat(document.getElementById('he-hrs').value) || 0,
descripcion: document.getElementById('he-desc').value,
};
if (payload.hrs <= 0) { toast('Horas debe ser > 0','error'); return; }
try {
const updated = await apiCall('PUT','update_hours', payload);
const r = findRow(editingHr.dept, editingHr.codigo);
if (r) Object.assign(r, adaptRow(updated));
const dept = editingHr.dept, codigo = editingHr.codigo;
closeHrModal();
renderPanel(dept);
setTimeout(()=>reopenDetail(codigo), 30);
toast('Hora actualizada');
} catch(e) { toast('Error: '+e.message, 'error'); }
}
async function deleteHour(hourId, dept, codigo){
const r = findRow(dept, codigo); if(!r) return;
const h = (r.hours||[]).find(x => Number(x.id) === Number(hourId)); if(!h) return;
if (!confirm(`¿Eliminar el registro de ${fmtHours(h.hrs)} para ${h.abogado}?`)) return;
try {
const updated = await apiCall('DELETE','delete_hours', null, 'id='+hourId);
Object.assign(r, adaptRow(updated));
renderPanel(dept);
setTimeout(()=>reopenDetail(codigo), 30);
toast('Hora eliminada');
} catch(e) { toast('Error: '+e.message, 'error'); }
}
async function addHours(dept,codigo){
const r=findRow(dept,codigo); if(!r) return;
const abog=document.getElementById('hr-abog-'+codigo);