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:
@@ -357,6 +357,62 @@ try {
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
}
|
||||
|
||||
if ($method === 'PUT' && $action === 'update_hours') {
|
||||
$b = body();
|
||||
$hourId = (int)($b['_id'] ?? 0);
|
||||
if (!$hourId) err('id requerido');
|
||||
|
||||
$sel = $pdo->prepare('SELECT id, tramite_id, autor_email, abogado, hrs, descripcion FROM tramites_hours WHERE id = :id');
|
||||
$sel->execute([':id' => $hourId]);
|
||||
$row = $sel->fetch();
|
||||
if (!$row) err('Registro de horas no encontrado', 404);
|
||||
|
||||
$tramiteId = (int)$row['tramite_id'];
|
||||
if (!canView($pdo, $tramiteId)) err('No tienes acceso al trámite', 403);
|
||||
if (!Auth::isSocio() && $row['autor_email'] !== $user['email']) {
|
||||
err('Solo quien registró las horas (o un Socio/Admin) puede editarlas', 403);
|
||||
}
|
||||
|
||||
$abog = trim((string)($b['abogado'] ?? $row['abogado']));
|
||||
$hrs = (float) ($b['hrs'] ?? $row['hrs']);
|
||||
$desc = trim((string)($b['descripcion'] ?? $row['descripcion']));
|
||||
if ($hrs <= 0) err('horas debe ser > 0');
|
||||
|
||||
$upd = $pdo->prepare('UPDATE tramites_hours SET abogado = :a, hrs = :h, descripcion = :d WHERE id = :id');
|
||||
$upd->execute([':a' => $abog, ':h' => $hrs, ':d' => $desc, ':id' => $hourId]);
|
||||
|
||||
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
|
||||
->execute([':u' => $user['email'], ':id' => $tramiteId]);
|
||||
|
||||
$r = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$r->execute([':id' => $tramiteId]);
|
||||
ok(hydrate($pdo, $r->fetch()));
|
||||
}
|
||||
|
||||
if ($method === 'DELETE' && $action === 'delete_hours') {
|
||||
$hourId = (int)($_GET['id'] ?? 0);
|
||||
if (!$hourId) err('id requerido');
|
||||
|
||||
$sel = $pdo->prepare('SELECT id, tramite_id, autor_email FROM tramites_hours WHERE id = :id');
|
||||
$sel->execute([':id' => $hourId]);
|
||||
$row = $sel->fetch();
|
||||
if (!$row) err('Registro de horas no encontrado', 404);
|
||||
|
||||
$tramiteId = (int)$row['tramite_id'];
|
||||
if (!canView($pdo, $tramiteId)) err('No tienes acceso al trámite', 403);
|
||||
if (!Auth::isSocio() && $row['autor_email'] !== $user['email']) {
|
||||
err('Solo quien registró las horas (o un Socio/Admin) puede eliminarlas', 403);
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM tramites_hours WHERE id = :id')->execute([':id' => $hourId]);
|
||||
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
|
||||
->execute([':u' => $user['email'], ':id' => $tramiteId]);
|
||||
|
||||
$r = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$r->execute([':id' => $tramiteId]);
|
||||
ok(hydrate($pdo, $r->fetch()));
|
||||
}
|
||||
|
||||
if ($method === 'DELETE' && $action === 'delete') {
|
||||
if (!Auth::canDelete()) err('Tu rol no permite eliminar trámites. Solo Socios y Abogados pueden.', 403);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- ============================================================
|
||||
-- Donna — Schema 009: Convertir timestamps UTC → America/Panama
|
||||
-- ============================================================
|
||||
-- Hasta aquí la app corrió con OS, MariaDB y PHP en UTC, pero los
|
||||
-- usuarios viven en Panamá (UTC-5). Causa: filtros de fecha en frontend
|
||||
-- (basados en hora local) excluían entries creados después de las
|
||||
-- 19:00 hora Panama porque su DATETIME en UTC ya era del día siguiente.
|
||||
--
|
||||
-- Aplicado junto con:
|
||||
-- - timedatectl set-timezone America/Panama (OS)
|
||||
-- - SET time_zone = '-05:00' por conexión (src/Db.php)
|
||||
-- - date_default_timezone_set('America/Panama') en src/Config.php
|
||||
|
||||
UPDATE users
|
||||
SET first_seen_at = CASE WHEN first_seen_at IS NOT NULL THEN CONVERT_TZ(first_seen_at, '+00:00', '-05:00') ELSE first_seen_at END,
|
||||
last_login_at = CASE WHEN last_login_at IS NOT NULL THEN CONVERT_TZ(last_login_at, '+00:00', '-05:00') ELSE last_login_at END;
|
||||
|
||||
UPDATE escrituras_tramites
|
||||
SET fecha_solicitud = CONVERT_TZ(fecha_solicitud, '+00:00', '-05:00'),
|
||||
ultima_actualizacion = CONVERT_TZ(ultima_actualizacion, '+00:00', '-05:00'),
|
||||
created_at = CONVERT_TZ(created_at, '+00:00', '-05:00');
|
||||
|
||||
UPDATE escrituras_historial
|
||||
SET modificado_en = CONVERT_TZ(modificado_en, '+00:00', '-05:00');
|
||||
|
||||
UPDATE tramites_legales
|
||||
SET fecha_solicitud = CONVERT_TZ(fecha_solicitud, '+00:00', '-05:00'),
|
||||
created_at = CONVERT_TZ(created_at, '+00:00', '-05:00'),
|
||||
updated_at = CONVERT_TZ(updated_at, '+00:00', '-05:00');
|
||||
|
||||
UPDATE tramites_obs SET fecha = CONVERT_TZ(fecha, '+00:00', '-05:00');
|
||||
UPDATE tramites_hours SET fecha = CONVERT_TZ(fecha, '+00:00', '-05:00');
|
||||
UPDATE tramites_involucrados SET added_at = CONVERT_TZ(added_at, '+00:00', '-05:00');
|
||||
@@ -3,8 +3,13 @@ declare(strict_types=1);
|
||||
|
||||
final class Config {
|
||||
private static ?array $env = null;
|
||||
private static bool $tzSet = false;
|
||||
|
||||
public static function load(string $path = '/etc/donna/donna.env'): void {
|
||||
if (!self::$tzSet) {
|
||||
date_default_timezone_set('America/Panama');
|
||||
self::$tzSet = true;
|
||||
}
|
||||
if (self::$env !== null) return;
|
||||
if (!is_readable($path)) {
|
||||
http_response_code(500);
|
||||
|
||||
@@ -19,6 +19,8 @@ final class Db {
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
// Hora de Panamá (UTC-5, sin DST) — defensivo aunque el OS ya esté en Panamá
|
||||
self::$pdo->exec("SET time_zone = '-05:00'");
|
||||
return self::$pdo;
|
||||
}
|
||||
}
|
||||
|
||||
+107
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user