Files
donna/views/tramites.html
T
Soporte ZH Consultores 1bb63f12f6 feat(tramites): app "Control de Trámites Legales" + backend
Migración del frontend localStorage a backend autenticado bajo Donna.

* SQL 004: tramites_legales (todos los campos por dept en columnas
  nullable + ENUMs Sí/No), tramites_obs (bitácora N:1), tramites_hours
  (registro de horas N:1), tramites_seq (contador por departamento).
  IDs server-side tipo ADM-001 / CRP-001 / MIG-001 / LIT-001 con
  SELECT FOR UPDATE para atomicidad.
* public/tramites/api.php: list/create/update/delete + set_status +
  add_obs + add_hours. Valida departamento y estatus contra los enums
  del frontend. Audita creado_por / actualizado_por con el email
  autenticado.
* views/tramites.html: artifact original adaptado. Quitado backup/restore
  localStorage (mysqldump diario ya hace ese trabajo). Cada mutación
  va vía fetch con manejo de 401 → redirect a login. Mapeo
  id<->codigo para preservar el contrato del artifact.
* Apps.php: la card "Gestión de Casos" placeholder se reemplaza por
  "Control de Trámites Legales" en estado live.
2026-05-11 20:03:28 +00:00

648 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Control de Trámites Legales — AALN</title>
<style>
:root{--navy:#1B3A6B;--gold:#C9A84C;--gold-light:#f5e6c2;--red:#c0392b;--green:#27ae60;--blue:#2980b9;--gray:#6c757d;--bg:#f4f6fa;--white:#fff;--border:#dee2e6;--text:#212529;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Segoe UI',Arial,sans-serif;background:var(--bg);color:var(--text);font-size:13px;}
header{background:var(--navy);color:#fff;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px rgba(0,0,0,.3);}
header h1{font-size:17px;font-weight:700;letter-spacing:.5px;}
header h1 span{color:var(--gold);}
.hdr-btns{display:flex;gap:8px;align-items:center;}
.hdr-btn{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#fff;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px;transition:.2s;text-decoration:none;}
.hdr-btn:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.hdr-user{display:flex;align-items:center;gap:8px;font-size:11.5px;color:rgba(255,255,255,.85);padding:4px 10px;background:rgba(255,255,255,.08);border-radius:14px;}
.sync-dot{width:7px;height:7px;border-radius:50%;background:#4CAF50;}
.sync-dot.syncing{background:var(--gold);}
.sync-dot.error{background:#ef5350;}
.tabs{display:flex;background:var(--navy);padding:0 16px;gap:2px;border-bottom:3px solid var(--gold);}
.tab{padding:10px 18px;color:rgba(255,255,255,.65);cursor:pointer;font-size:12.5px;font-weight:600;border-radius:4px 4px 0 0;transition:.2s;white-space:nowrap;}
.tab:hover{color:#fff;background:rgba(255,255,255,.1);}
.tab.active{background:var(--gold);color:var(--navy);}
.panel{display:none;padding:16px;}
.panel.active{display:block;}
.toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;}
.toolbar input,.toolbar select{padding:6px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:#fff;}
.toolbar input{flex:1;min-width:160px;}
.btn{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;transition:.2s;}
.btn-primary{background:var(--navy);color:#fff;}
.btn-primary:hover{background:#14305a;}
.btn-gold{background:var(--gold);color:var(--navy);}
.btn-gold:hover{background:#b8923e;}
.btn-sm{padding:3px 9px;font-size:11px;}
.btn-danger{background:#e74c3c;color:#fff;}
.btn-danger:hover{background:#c0392b;}
.tbl-wrap{overflow-x:auto;background:#fff;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.08);}
table{width:100%;border-collapse:collapse;font-size:12px;}
th{background:var(--navy);color:#fff;padding:8px 10px;text-align:left;font-weight:600;white-space:nowrap;}
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;}
tr:hover>td{background:#f8f9ff;}
tr.expanded>td{background:#fffdf5;}
.toggle-btn{background:none;border:none;cursor:pointer;font-size:14px;color:var(--navy);font-weight:700;}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10.5px;font-weight:700;white-space:nowrap;}
.b-solicitado{background:#e8f4fd;color:#1a6898;}
.b-recibido{background:#e8f0e8;color:#2e6b2e;}
.b-enproceso,.b-borrador,.b-presentado{background:#fff3cd;color:#856404;}
.b-notificado,.b-admitido{background:#d1ecf1;color:#0c5460;}
.b-recurso,.b-apelacion{background:#f8d7da;color:#721c24;}
.b-culminado,.b-firmado,.b-sentencia{background:#d4edda;color:#155724;}
.b-facturado{background:#e2e3e5;color:#383d41;}
.b-versionfirma{background:#fce4ec;color:#880e4f;}
.b-contestado,.b-pruebas{background:#ede7f6;color:#4527a0;}
.b-contrapruebas,.b-alegatos{background:#e3f2fd;color:#0d47a1;}
.b-civil{background:#bbdefb;color:#0d47a1;}
.b-penal{background:#ffcdd2;color:#b71c1c;}
.b-primera{background:#fff9c4;color:#f57f17;}
.b-segunda{background:#ffe0b2;color:#e65100;}
.b-csj{background:#f3e5f5;color:#6a1b9a;}
.b-si{background:#d4edda;color:#155724;}
.b-no{background:#f8d7da;color:#721c24;}
.detail-row td{background:#fafbfc;padding:0;}
.detail-inner{padding:14px 16px;border-left:4px solid var(--gold);}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;}
@media(max-width:700px){.detail-grid{grid-template-columns:1fr;}}
.detail-card{background:#fff;border:1px solid var(--border);border-radius:6px;padding:10px 14px;}
.detail-card h4{font-size:11px;font-weight:700;color:var(--navy);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);padding-bottom:5px;}
.detail-card p{font-size:12px;margin-bottom:4px;line-height:1.5;}
.detail-card p b{color:var(--navy);}
.instr-banner{background:var(--gold-light);border:1px solid var(--gold);border-radius:5px;padding:8px 12px;font-size:12px;margin-bottom:12px;color:#6b4c00;}
.obs-list{list-style:none;max-height:140px;overflow-y:auto;}
.obs-list li{font-size:11.5px;padding:5px 0;border-bottom:1px dashed #eee;line-height:1.5;}
.obs-list li .obs-meta{font-size:10px;color:var(--gray);}
.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 .hl-who{color:var(--navy);font-weight:600;}
.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;}
.progress-wrap{margin:8px 0;}
.progress-label{font-size:10px;color:var(--gray);margin-bottom:3px;}
.progress-bar{height:8px;background:#e9ecef;border-radius:4px;overflow:hidden;}
.progress-fill{height:100%;border-radius:4px;background:var(--gold);transition:.3s;}
.vence-alert{color:var(--red);font-weight:700;font-size:10.5px;}
.last-upd{font-size:10px;color:var(--gray);font-style:italic;}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;align-items:center;justify-content:center;}
.modal-overlay.open{display:flex;}
.modal{background:#fff;border-radius:8px;width:96%;max-width:580px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,.25);}
.modal-header{background:var(--navy);color:#fff;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;border-radius:8px 8px 0 0;position:sticky;top:0;z-index:1;}
.modal-header h3{font-size:14px;}
.modal-close{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;line-height:1;}
.modal-body{padding:18px;}
.form-group{margin-bottom:12px;}
.form-group label{display:block;font-size:11.5px;font-weight:700;color:var(--navy);margin-bottom:4px;text-transform:uppercase;letter-spacing:.4px;}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:4px;font-size:12.5px;}
.form-group textarea{resize:vertical;min-height:56px;}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.modal-footer{padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;}
.counts{display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.count-card{background:#fff;border-radius:6px;padding:8px 14px;border-left:4px solid var(--navy);box-shadow:0 1px 3px rgba(0,0,0,.07);font-size:12px;}
.count-card b{font-size:18px;display:block;color:var(--navy);}
.empty-state{text-align:center;padding:40px;color:var(--gray);font-size:13px;}
.toast{position:fixed;bottom:24px;right:24px;background:#1B3A6B;color:#fff;padding:10px 18px;border-radius:6px;font-size:12px;box-shadow:0 4px 14px rgba(0,0,0,.2);border-left:3px solid var(--gold);transform:translateY(20px);opacity:0;transition:.25s;z-index:2000;}
.toast.show{transform:translateY(0);opacity:1;}
.toast.error{border-left-color:#ef5350;}
</style>
</head>
<body>
<header>
<h1>⚖️ <span>AALN</span> — Control de Trámites Legales</h1>
<div class="hdr-btns">
<span class="hdr-user">
<span class="sync-dot" id="sync-dot"></span>
<span id="sync-label">Cargando…</span>
</span>
<a class="hdr-btn" href="/">← Portal</a>
<a class="hdr-btn" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?')">Salir</a>
</div>
</header>
<div class="tabs">
<div class="tab active" onclick="switchTab('adm')">🏛 Administrativo</div>
<div class="tab" onclick="switchTab('corp')">⚖️ Corp · Com · Civil</div>
<div class="tab" onclick="switchTab('mig')">✈️ Migración</div>
<div class="tab" onclick="switchTab('lit')">⚔️ Litigios</div>
</div>
<div id="panel-adm" class="panel active"></div>
<div id="panel-corp" class="panel"></div>
<div id="panel-mig" class="panel"></div>
<div id="panel-lit" class="panel"></div>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Nuevo Trámite</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary" onclick="saveModal()">Guardar</button>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const ABOGADOS = ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés'];
const PROVINCIAS = ['Bocas del Toro','Chiriquí','Coclé','Colón','Darién','Herrera','Los Santos','Panamá','Panamá Oeste','Veraguas','Comarca Ngäbe-Buglé','Comarca Guna Yala','Comarca Emberá-Wounaan'];
const STATUS_MAP = {
adm: ['Solicitado','Recibido','En Proceso','Notificado','Recurso','Culminado','Facturado'],
corp: ['Solicitado','Recibido','Borrador','Versión de Firma','Firmado','Culminado','Facturado'],
mig: ['Solicitado','Recibido','Presentado','Notificado','Recurso','Culminado','Facturado'],
lit: ['Solicitado','Recibido','Presentado','Admitido','Contestado','Pruebas','Contrapruebas','Alegatos','Sentencia','Apelación']
};
const DEPT_LABELS = {adm:'Trámite Admin',corp:'Corp/Com/Civil',mig:'Migración',lit:'Litigios'};
const SI_NO = ['Sí','No'];
const API = 'api.php';
let data = {adm:[],corp:[],mig:[],lit:[]};
let currentDept = 'adm';
let editingId = null;
let authUser = {email:'', name:''};
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function setSync(state,label){
const dot=document.getElementById('sync-dot');
dot.className='sync-dot'+(state!=='ok'?' '+state:'');
document.getElementById('sync-label').textContent=label;
}
function toast(msg,type='ok'){
const el=document.getElementById('toast');
el.textContent=msg;
el.className='toast show'+(type==='error'?' error':'');
setTimeout(()=>{el.className='toast'+(type==='error'?' error':'');},3000);
}
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 fmtDateOnly(dt){
if(!dt) return '';
if(/^\d{4}-\d{2}-\d{2}$/.test(dt)) {
const [y,m,d]=dt.split('-');
return `${d}/${m}/${y}`;
}
return dt;
}
async function apiCall(method, action, body=null, query=''){
const url = API + '?action=' + action + (query?'&'+query:'');
const opts = {method, headers:{'Accept':'application/json'}, credentials:'same-origin'};
if(body){ opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); }
const res = await fetch(url, opts);
let j;
try { j = await res.json(); } catch(e){ throw new Error('Respuesta inválida del servidor'); }
if(res.status===401 && j.auth_required){
window.location.href = j.login_url || '/auth/login.php?return_to=' + encodeURIComponent(location.pathname);
return null;
}
if(!res.ok || !j.ok) throw new Error(j.error || ('HTTP '+res.status));
if(j.user) authUser = j.user;
return j.data;
}
function adaptRow(r){
return {
...r,
_id: r.id,
id : r.codigo,
fechaSolicitud: fmtDate(r.fecha_solicitud),
docCompleta: r.doc_completa,
permisoTrabajo: r.permiso_trabajo,
dependientes: r.dependientes,
fechaVencimiento: r.fecha_vencimiento || '',
tipoDocumento: r.tipo_documento,
tipoTramite: r.tipo_tramite,
tipoProceso: r.tipo_proceso,
fechaTermino: r.fecha_termino || '',
obs: (r.obs||[]).map(o=>({...o, fecha:fmtDate(o.fecha)})),
hours: (r.hours||[]).map(h=>({...h, fecha:fmtDate(h.fecha)})),
};
}
async function loadAll(){
setSync('syncing','Cargando…');
try {
const payload = await apiCall('GET','list');
if(payload === null) return;
data = {
adm: (payload.adm || []).map(adaptRow),
corp: (payload.corp || []).map(adaptRow),
mig: (payload.mig || []).map(adaptRow),
lit: (payload.lit || []).map(adaptRow),
};
setSync('ok', authUser.email ? authUser.email.split('@')[0] : 'Conectado');
renderAll();
} catch(e){
setSync('error','Sin conexión');
toast('Error al cargar: '+e.message,'error');
}
}
function switchTab(dept){
currentDept = dept;
document.querySelectorAll('.tab').forEach((t,i)=>{
t.classList.toggle('active',['adm','corp','mig','lit'][i]===dept);
});
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.getElementById('panel-'+dept).classList.add('active');
renderPanel(dept);
}
function renderAll(){['adm','corp','mig','lit'].forEach(renderPanel);}
function renderPanel(dept){
const panel=document.getElementById('panel-'+dept);
const rows=data[dept];
const statuses=STATUS_MAP[dept];
const search=(panel.querySelector('.search-input')||{value:''}).value.toLowerCase();
const fStatus=(panel.querySelector('.f-status')||{value:''}).value;
const fAbog=(panel.querySelector('.f-abog')||{value:''}).value;
const fEsfera=(panel.querySelector('.f-esfera')||{value:''}).value;
let filtered=rows.filter(r=>{
const txt=((r.nombre||r.tipoTramite||r.tipoProceso||'')+(r.cliente||'')+(r.abogado||'')+(r.id||'')).toLowerCase();
if(search && !txt.includes(search)) return false;
if(fStatus && r.estatus!==fStatus) return false;
if(fAbog && r.abogado!==fAbog) return false;
if(dept==='lit' && fEsfera && r.esfera!==fEsfera) return false;
return true;
});
const total=rows.length;
const activos=rows.filter(r=>r.estatus!=='Culminado'&&r.estatus!=='Facturado').length;
const culminados=rows.filter(r=>r.estatus==='Culminado'||r.estatus==='Facturado').length;
panel.innerHTML=`
<div class="counts">
<div class="count-card"><b>${total}</b>Total</div>
<div class="count-card"><b>${activos}</b>Activos</div>
<div class="count-card"><b>${culminados}</b>Cerrados</div>
</div>
<div class="toolbar">
<input class="search-input" placeholder="Buscar..." oninput="renderPanel('${dept}')" value="${esc(search)}">
<select class="f-status" onchange="renderPanel('${dept}')">
<option value="">— Estatus —</option>
${statuses.map(s=>`<option${fStatus===s?' selected':''}>${s}</option>`).join('')}
</select>
<select class="f-abog" onchange="renderPanel('${dept}')">
<option value="">— Abogado —</option>
${ABOGADOS.map(a=>`<option${fAbog===a?' selected':''}>${a}</option>`).join('')}
</select>
${dept==='lit'?`<select class="f-esfera" onchange="renderPanel('${dept}')">
<option value="">— Esfera —</option>
<option${fEsfera==='Civil'?' selected':''}>Civil</option><option${fEsfera==='Penal'?' selected':''}>Penal</option>
</select>`:''}
<button class="btn btn-primary" onclick="openModal('${dept}',null)">+ Nuevo</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>${buildHeaders(dept)}</tr></thead>
<tbody id="tbody-${dept}">
${filtered.length?filtered.map(r=>buildRow(dept,r)).join(''):`<tr><td colspan="20"><div class="empty-state">Sin registros. Haz clic en <b>+ Nuevo</b> para agregar.</div></td></tr>`}
</tbody>
</table>
</div>`;
}
function buildHeaders(dept){
const base=`<th></th><th>ID</th>`;
if(dept==='adm') return base+`<th>Institución</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`;
if(dept==='corp') return base+`<th>Tipo Documento</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`;
if(dept==='mig') return base+`<th>Tipo Trámite</th><th>Cliente</th><th>Abogado</th><th>Doc. Completa</th><th>Perm. Trabajo</th><th>Dependientes</th><th>Estatus</th><th>Fecha Solicitud</th><th>Vencimiento</th><th>Horas</th><th>Acciones</th>`;
if(dept==='lit') return base+`<th>Proceso / Juzgado</th><th>Cliente</th><th>Abogado</th><th>Esfera</th><th>Instancia</th><th>Doc. Completa</th><th>Incidentes</th><th>Amparo</th><th>Estatus</th><th>Fecha Solicitud</th><th>Término</th><th>Horas</th><th>Acciones</th>`;
}
function statusSlug(s){return (s||'').toLowerCase().replace(/\s+/g,'').replace(/\./g,'').replace(/[éèê]/g,'e').replace(/[áàâ]/g,'a').replace(/[íì]/g,'i').replace(/[óò]/g,'o').replace(/[úù]/g,'u');}
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 totalHours(r){
if(!r.hours||!r.hours.length) return 0;
return r.hours.reduce((s,h)=>s+(parseFloat(h.hrs)||0),0).toFixed(1);
}
function venceAlert(fecha){
if(!fecha) return '';
const diff=(new Date(fecha)-new Date())/(1000*60*60*24);
if(diff<0) return `<br><span class="vence-alert">⚠ Vencido</span>`;
if(diff<=30) return `<br><span class="vence-alert">⚠ ${Math.round(diff)}d</span>`;
return '';
}
function buildRow(dept,r){
let cells='';
if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><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><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</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><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==='lit'){
const esfera=r.esfera||'';
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><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>`;
}
const actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> <button class="btn btn-danger btn-sm" onclick="deleteRow('${dept}','${r.id}');event.stopPropagation()">✕</button></td>`;
return `<tr id="row-${r.id}" onclick="toggleDetail(event,'${dept}','${r.id}')">
<td><button class="toggle-btn" id="tog-${r.id}">▶</button></td>
<td><b>${esc(r.id)}</b></td>
${cells}${actions}
</tr>
<tr class="detail-row" id="det-${r.id}" style="display:none">
<td colspan="20">${buildDetail(dept,r)}</td>
</tr>`;
}
function buildDetail(dept,r){
let progress='';
if(dept==='lit'){
const steps=STATUS_MAP.lit;
const idx=steps.indexOf(r.estatus);
const pct=idx>=0?Math.round(((idx+1)/steps.length)*100):0;
const col=pct>=70?'#27ae60':pct>=40?'#2980b9':'var(--gold)';
progress=`<div class="progress-wrap">
<div class="progress-label">Avance procesal: ${pct}% — ${esc(r.estatus)||'—'}</div>
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${col}"></div></div>
</div>`;
}
let ficha='';
if(dept==='adm') ficha=`<p><b>Institución:</b> ${esc(r.institucion)||'—'}</p><p><b>Nombre:</b> ${esc(r.nombre)||'—'}</p>`;
if(dept==='corp') ficha=`<p><b>Tipo Documento:</b> ${esc(r.tipoDocumento)||'—'}</p><p><b>Nombre:</b> ${esc(r.nombre)||'—'}</p>`;
if(dept==='mig') ficha=`<p><b>Tipo:</b> ${esc(r.tipoTramite)||'—'}</p><p><b>Doc. Completa:</b> ${esc(r.docCompleta)||'—'}</p><p><b>Permiso Trabajo:</b> ${esc(r.permisoTrabajo)||'—'}</p><p><b>Dependientes:</b> ${esc(r.dependientes)||'—'}</p><p><b>Vencimiento:</b> ${fmtDateOnly(r.fechaVencimiento)||'—'}</p>`;
if(dept==='lit') ficha=`<p><b>Esfera:</b> ${esc(r.esfera)||'—'}</p><p><b>Instancia:</b> ${esc(r.instancia)||'—'}</p><p><b>Circuito:</b> ${esc(r.circuito)||'—'}</p><p><b>Juzgado:</b> ${esc(r.juzgado)||'—'}</p><p><b>Tipo Proceso:</b> ${esc(r.tipoProceso)||'—'}</p><p><b>Incidentes:</b> ${esc(r.incidentes)||'—'} | <b>Amparo:</b> ${esc(r.amparo)||'—'}</p><p><b>Término:</b> ${fmtDateOnly(r.fechaTermino)||'—'}</p>`;
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>${h.hrs} h · ${esc(h.fecha)}</span></li>`).join('');
const totalH=totalHours(r);
const statusSel=`<select onchange="changeStatus('${dept}','${r.id}',this.value)" onclick="event.stopPropagation()">
${STATUS_MAP[dept].map(s=>`<option${r.estatus===s?' selected':''}>${esc(s)}</option>`).join('')}
</select>`;
return `<div class="detail-inner">
${progress}
${r.instrucciones?`<div class="instr-banner">📌 <b>Instrucciones:</b> ${esc(r.instrucciones)}</div>`:''}
<div class="detail-grid">
<div class="detail-card">
<h4>Ficha del Trámite</h4>
${ficha}
<p><b>Cliente:</b> ${esc(r.cliente)||'—'}</p>
<p><b>Abogado:</b> ${esc(r.abogado)||'—'}</p>
<p><b>Fecha Solicitud:</b> ${esc(r.fechaSolicitud)||'—'}</p>
<p><b>Estatus:</b> ${statusSel}</p>
</div>
<div class="detail-card">
<h4>Observaciones</h4>
${obs?`<ul class="obs-list">${obs}</ul>`:'<p style="color:var(--gray);font-size:11px">Sin observaciones.</p>'}
${lastUpd}
<div class="add-obs" onclick="event.stopPropagation()">
<textarea id="obs-txt-${r.id}" placeholder="Nueva observación..."></textarea>
<select id="obs-abog-${r.id}">${ABOGADOS.map(a=>`<option>${a}</option>`).join('')}</select>
<button class="btn btn-primary btn-sm" onclick="addObs('${dept}','${r.id}')">+</button>
</div>
</div>
<div class="detail-card">
<h4>Horas Incurridas</h4>
${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="add-hours" onclick="event.stopPropagation()">
<select id="hr-abog-${r.id}">${ABOGADOS.map(a=>`<option>${a}</option>`).join('')}</select>
<input type="number" id="hr-val-${r.id}" min="0.1" step="0.1" placeholder="Horas" style="width:70px">
<input type="text" id="hr-desc-${r.id}" placeholder="Descripción" style="flex:1">
<button class="btn btn-primary btn-sm" onclick="addHours('${dept}','${r.id}')">+</button>
</div>
</div>
</div>
</div>`;
}
function findRow(dept,codigo){return data[dept].find(x=>x.id===codigo);}
function toggleDetail(e,dept,codigo){
if(e.target.tagName==='BUTTON'||e.target.tagName==='SELECT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT') return;
const det=document.getElementById('det-'+codigo);
const tog=document.getElementById('tog-'+codigo);
const row=document.getElementById('row-'+codigo);
if(!det) return;
const open=det.style.display==='table-row';
det.style.display=open?'none':'table-row';
tog.textContent=open?'▶':'▼';
row.classList.toggle('expanded',!open);
}
async function changeStatus(dept,codigo,val){
const r=findRow(dept,codigo); if(!r) return;
const prev=r.estatus;
r.estatus=val;
try {
await apiCall('PATCH','set_status',{_id:r._id, estatus:val});
toast('Estatus actualizado');
renderPanel(dept);
} catch(e){
r.estatus=prev;
toast('Error: '+e.message,'error');
renderPanel(dept);
}
}
async function addObs(dept,codigo){
const r=findRow(dept,codigo); if(!r) return;
const txt =document.getElementById('obs-txt-' +codigo);
const abog=document.getElementById('obs-abog-'+codigo);
if(!txt||!txt.value.trim()){toast('Escribe la observación','error');return;}
try {
const updated = await apiCall('POST','add_obs',{_id:r._id, texto:txt.value.trim(), abogado:abog.value});
Object.assign(r, adaptRow(updated));
renderPanel(dept);
setTimeout(()=>reopenDetail(codigo), 30);
toast('Observación añadida');
} 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);
const val =document.getElementById('hr-val-' +codigo);
const desc=document.getElementById('hr-desc-'+codigo);
if(!val||!val.value||parseFloat(val.value)<=0){toast('Horas debe ser > 0','error');return;}
try {
const updated = await apiCall('POST','add_hours',{_id:r._id, abogado:abog.value, hrs:parseFloat(val.value), descripcion:desc?desc.value:''});
Object.assign(r, adaptRow(updated));
renderPanel(dept);
setTimeout(()=>reopenDetail(codigo), 30);
toast('Horas registradas');
} catch(e){ toast('Error: '+e.message,'error'); }
}
function reopenDetail(codigo){
const det=document.getElementById('det-'+codigo);
const tog=document.getElementById('tog-'+codigo);
const row=document.getElementById('row-'+codigo);
if(det){det.style.display='table-row';if(tog)tog.textContent='▼';if(row)row.classList.add('expanded');}
}
async function deleteRow(dept,codigo){
const r=findRow(dept,codigo); if(!r) return;
if(!confirm('¿Eliminar trámite '+codigo+'? Esta acción no se puede deshacer (incluye observaciones e horas).')) return;
try {
await apiCall('DELETE','delete',null,'id='+r._id);
data[dept]=data[dept].filter(x=>x._id!==r._id);
renderPanel(dept);
toast('Trámite eliminado');
} catch(e){ toast('Error: '+e.message,'error'); }
}
let _modalDept='';
function openModal(dept,codigo){
_modalDept=dept;
const r=codigo?findRow(dept,codigo):null;
editingId=r?r._id:null;
document.getElementById('modal-title').textContent=r?('Editar '+r.id):('Nuevo Trámite — '+DEPT_LABELS[dept]);
document.getElementById('modal-body').innerHTML=buildForm(dept,r);
document.getElementById('modal').classList.add('open');
}
function closeModal(){document.getElementById('modal').classList.remove('open');editingId=null;}
function sel(arr,val,id){return `<select id="${id}">${arr.map(a=>`<option${a===val?' selected':''}>${a}</option>`).join('')}</select>`;}
function selBlank(arr,val,id){
const opts = `<option value=""></option>` + arr.map(a=>`<option${a===val?' selected':''}>${a}</option>`).join('');
return `<select id="${id}">${opts}</select>`;
}
function inp(id,val,type='text',ph=''){return `<input type="${type}" id="${id}" value="${esc(val||'')}" placeholder="${esc(ph)}">`;}
function fld(label,html){return `<div class="form-group"><label>${label}</label>${html}</div>`;}
function buildForm(dept,r){
const abogSel=sel(ABOGADOS,r?.abogado,'f-abogado');
const instrTxt=`<textarea id="f-instrucciones">${esc(r?.instrucciones||'')}</textarea>`;
const clienteInp=inp('f-cliente',r?.cliente,'text','Nombre del cliente');
const estatusSel=sel(STATUS_MAP[dept],r?.estatus,'f-estatus');
if(dept==='adm') return `
${fld('Institución',inp('f-institucion',r?.institucion,'text','Ej: Municipio de Panamá'))}
${fld('Nombre del Trámite',inp('f-nombre',r?.nombre,'text','Nombre descriptivo'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
${fld('Estatus',estatusSel)}`;
if(dept==='corp') return `
${fld('Tipo de Documento',inp('f-tipoDocumento',r?.tipoDocumento,'text','Ej: Contrato, Acta, Estatutos...'))}
${fld('Nombre del Trámite',inp('f-nombre',r?.nombre,'text','Nombre descriptivo'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
${fld('Estatus',estatusSel)}`;
if(dept==='mig') return `
${fld('Tipo de Trámite',inp('f-tipoTramite',r?.tipoTramite,'text','Ej: Residencia, Visa, Naturalización...'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
<div class="form-row">
${fld('Documentación Completa',selBlank(SI_NO,r?.docCompleta,'f-docCompleta'))}
${fld('Permiso de Trabajo',selBlank(SI_NO,r?.permisoTrabajo,'f-permisoTrabajo'))}
</div>
<div class="form-row">
${fld('Dependientes',selBlank(SI_NO,r?.dependientes,'f-dependientes'))}
${fld('Fecha de Vencimiento',inp('f-fechaVencimiento',r?.fechaVencimiento,'date'))}
</div>
${fld('Estatus',estatusSel)}`;
if(dept==='lit') return `
<div class="form-row">
${fld('Esfera',selBlank(['Civil','Penal'],r?.esfera,'f-esfera'))}
${fld('Instancia',selBlank(['Primera','Segunda','CSJ'],r?.instancia,'f-instancia'))}
</div>
<div class="form-row">
${fld('Circuito (Provincia)',selBlank(PROVINCIAS,r?.circuito,'f-circuito'))}
${fld('Juzgado',inp('f-juzgado',r?.juzgado,'text','Ej: 3er Juzgado Civil'))}
</div>
${fld('Tipo de Proceso',inp('f-tipoProceso',r?.tipoProceso,'text','Ej: Ejecutivo, Ordinario, Penal...'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
<div class="form-row">
${fld('Documentación Completa',selBlank(SI_NO,r?.docCompleta,'f-docCompleta'))}
${fld('Incidentes',selBlank(SI_NO,r?.incidentes,'f-incidentes'))}
</div>
<div class="form-row">
${fld('Amparo',selBlank(SI_NO,r?.amparo,'f-amparo'))}
${fld('Fecha de Término',inp('f-fechaTermino',r?.fechaTermino,'date'))}
</div>
${fld('Estatus',estatusSel)}`;
}
function g(id){const el=document.getElementById(id);return el?el.value:'';}
async function saveModal(){
const dept=_modalDept;
const payload = {
departamento: dept,
cliente: g('f-cliente'),
abogado: g('f-abogado'),
estatus: g('f-estatus'),
instrucciones: g('f-instrucciones'),
};
if(dept==='adm'){ payload.institucion=g('f-institucion'); payload.nombre=g('f-nombre'); }
if(dept==='corp'){ payload.tipoDocumento=g('f-tipoDocumento'); payload.nombre=g('f-nombre'); }
if(dept==='mig'){
payload.tipoTramite = g('f-tipoTramite');
payload.docCompleta = g('f-docCompleta');
payload.permisoTrabajo = g('f-permisoTrabajo');
payload.dependientes = g('f-dependientes');
payload.fechaVencimiento= g('f-fechaVencimiento');
}
if(dept==='lit'){
payload.esfera = g('f-esfera');
payload.instancia = g('f-instancia');
payload.circuito = g('f-circuito');
payload.juzgado = g('f-juzgado');
payload.tipoProceso = g('f-tipoProceso');
payload.docCompleta = g('f-docCompleta');
payload.incidentes = g('f-incidentes');
payload.amparo = g('f-amparo');
payload.fechaTermino = g('f-fechaTermino');
}
try {
let saved;
if(editingId){
payload._id = editingId;
saved = await apiCall('PUT','update',payload);
} else {
saved = await apiCall('POST','create',payload);
}
const adapted = adaptRow(saved);
const idx = data[dept].findIndex(r => r._id === adapted._id);
if(idx >= 0) data[dept][idx] = adapted; else data[dept].unshift(adapted);
closeModal();
renderPanel(dept);
toast(editingId?'Trámite actualizado':'Trámite creado: '+adapted.id);
} catch(e){
toast('Error al guardar: '+e.message,'error');
}
}
loadAll();
</script>
</body>
</html>