Antes solo miraba nombre/tipoTramite/tipoProceso/cliente/abogado/id,
así que buscar "Municipio" en la tab Administrativo no encontraba
nada aunque la columna Institución tuviera "Municipio de Panamá".
Nuevo rowHaystack(dept, r) concatena todos los campos que se
muestran en la tabla, excluyendo abogado y estatus (tienen sus
propios dropdowns al lado del buscador):
- Común: id/codigo, cliente, nombre, instrucciones, fecha solicitud
- adm: institución
- corp: tipo documento
- mig: tipo trámite, doc completa, permiso trabajo, dependientes,
vencimiento
- lit: tipo proceso, juzgado, circuito, esfera, instancia,
doc completa, incidentes, amparo, término
- Todos: involucrados (name + email)
1058 lines
54 KiB
HTML
1058 lines
54 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 — AAL</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@400;500;600;700&display=swap" rel="stylesheet">
|
|
<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 — estandarizado con Control de Escrituras */
|
|
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;font-family:'DM Sans',system-ui,sans-serif;}
|
|
.header-left{display:flex;align-items:center;gap:14px;}
|
|
.btn-back{display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:rgba(255,255,255,0.9);padding:6px 12px 6px 9px;border-radius:8px;font-size:12px;text-decoration:none;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',Georgia,serif;font-size:15px;color:var(--navy);flex-shrink:0;}
|
|
.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,0.85);}
|
|
.header-right{display:flex;align-items:center;gap:16px;}
|
|
.sync-status{display:flex;align-items:center;gap:6px;font-size:11px;color:rgba(255,255,255,0.55);}
|
|
.sync-dot{width:7px;height:7px;border-radius:50%;background:#4CAF50;animation:pulse 2.5s ease-in-out infinite;}
|
|
.sync-dot.syncing{background:var(--gold);}
|
|
.sync-dot.error{background:#ef5350;animation:none;}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
|
|
.user-chip{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.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;flex-shrink:0;font-family:'DM Sans',sans-serif;}
|
|
.user-name{font-size:12px;color:rgba(255,255,255,0.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,0.20);border-radius:6px;background:transparent;color:rgba(255,255,255,0.70);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;text-decoration:none;display:inline-block;line-height:1.6;}
|
|
.btn-logout:hover{background:rgba(255,255,255,0.10);color:#fff;border-color:rgba(255,255,255,0.30);}
|
|
.btn-gear,.btn-clock{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;cursor:pointer;font-family:'DM Sans',sans-serif;}
|
|
.btn-gear:hover,.btn-clock:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
|
|
.btn-gear svg,.btn-clock svg{width:16px;height:16px;}
|
|
.inv-stack{display:inline-flex;align-items:center;gap:3px;}
|
|
.inv-avatar{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:var(--navy);color:#fff;font-size:9.5px;font-weight:700;border:1.5px solid #fff;cursor:default;font-family:'DM Sans',sans-serif;}
|
|
.inv-more{font-size:10px;color:var(--gray);margin-left:3px;}
|
|
.inv-picker{display:flex;flex-direction:column;gap:6px;}
|
|
.inv-chips{display:flex;flex-wrap:wrap;gap:5px;padding:6px;min-height:36px;border:1px solid var(--border);border-radius:4px;background:#fafbfc;}
|
|
.inv-chips:empty::after{content:'Ningún involucrado seleccionado';color:#999;font-size:11px;padding:3px;align-self:center;}
|
|
.inv-chip{display:inline-flex;align-items:center;gap:4px;background:var(--navy);color:#fff;padding:3px 4px 3px 10px;border-radius:14px;font-size:11px;}
|
|
.inv-chip button{background:transparent;border:none;color:rgba(255,255,255,.7);cursor:pointer;padding:0 5px;font-size:14px;line-height:1;}
|
|
.inv-chip button:hover{color:#fff;}
|
|
.inv-picker select{width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:#fff;}
|
|
.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;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;}
|
|
.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>
|
|
<link rel="stylesheet" href="/assets/donna-export.css">
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="header-left">
|
|
<a class="btn-back" href="/" title="Volver al portal Donna">
|
|
<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">AAL</span>
|
|
<span class="logo-tool">Control de Trámites Legales</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="sync-status">
|
|
<div class="sync-dot" id="sync-dot"></div>
|
|
<span id="sync-label">Conectando…</span>
|
|
</div>
|
|
<div class="user-chip">
|
|
<div class="user-avatar" id="user-avatar">?</div>
|
|
<span class="user-name" id="user-name">Cargando…</span>
|
|
</div>
|
|
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</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>
|
|
|
|
<!-- 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 src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
|
<script src="/assets/donna-export.js"></script>
|
|
<script>
|
|
const ABOGADOS = (window.DONNA && window.DONNA.abogados) || ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés'];
|
|
const USERS = (window.DONNA && window.DONNA.users) || [];
|
|
const MY_EMAIL = (window.DONNA && window.DONNA.me) || '';
|
|
const MY_ID = (USERS.find(u => u.email === MY_EMAIL) || {}).id || 0;
|
|
const CAN_CREATE = !!(window.DONNA && window.DONNA.canCreate);
|
|
const CAN_DELETE = !!(window.DONNA && window.DONNA.canDelete);
|
|
const IS_SOCIO = !!(window.DONNA && window.DONNA.isSocio);
|
|
let currentInvIds = new Set();
|
|
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:''};
|
|
let firstLoadDone = false; // deep-link ?edit=CODIGO solo aplica en la primera carga
|
|
|
|
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');}
|
|
function setSync(state,label){
|
|
const dot=document.getElementById('sync-dot');
|
|
dot.className='sync-dot'+(state!=='ok'?' '+state:'');
|
|
document.getElementById('sync-label').textContent=label;
|
|
}
|
|
function initials(text){
|
|
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
|
|
const a=(parts[0]||'').charAt(0);
|
|
const b=(parts[1]||'').charAt(0);
|
|
return (a+b).toUpperCase()||'?';
|
|
}
|
|
function setUserChip(){
|
|
const email=authUser.email||'';
|
|
const name=authUser.name||email;
|
|
const av=document.getElementById('user-avatar');
|
|
const nm=document.getElementById('user-name');
|
|
if(av) av.textContent=initials(name||email);
|
|
if(nm) { nm.textContent=name||email||'Conectado'; nm.title=email; }
|
|
}
|
|
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', 'Sincronizado');
|
|
setUserChip();
|
|
renderAll();
|
|
if (!firstLoadDone) {
|
|
firstLoadDone = true;
|
|
checkDeepLink();
|
|
}
|
|
} 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);
|
|
}
|
|
|
|
// Si el usuario llegó desde el Calendario con ?edit=ADM-001, busca el dept y abre el modal
|
|
function checkDeepLink(){
|
|
const params = new URLSearchParams(location.search);
|
|
const code = params.get('edit');
|
|
if(!code) return;
|
|
for(const dept of ['adm','corp','mig','lit']){
|
|
const r = data[dept].find(x => x.id === code);
|
|
if(r){
|
|
switchTab(dept);
|
|
openModal(dept, code);
|
|
history.replaceState({}, '', location.pathname);
|
|
return;
|
|
}
|
|
}
|
|
toast('Trámite ' + code + ' no encontrado','error');
|
|
history.replaceState({}, '', location.pathname);
|
|
}
|
|
|
|
function renderAll(){['adm','corp','mig','lit'].forEach(renderPanel);}
|
|
|
|
// Estado de filtros por depto (persiste entre re-renders — no vive en el DOM,
|
|
// para que actualizar el <tbody> no destruya el input de búsqueda).
|
|
const filterState = {
|
|
adm: { search:'', status:'', abog:'', esfera:'' },
|
|
corp: { search:'', status:'', abog:'', esfera:'' },
|
|
mig: { search:'', status:'', abog:'', esfera:'' },
|
|
lit: { search:'', status:'', abog:'', esfera:'' },
|
|
};
|
|
|
|
// Concatena todos los campos visibles de la fila (excepto abogado y estatus,
|
|
// que tienen su propio dropdown). Incluye involucrados (name+email) e
|
|
// instrucciones para que la búsqueda encuentre lo mismo que el usuario ve.
|
|
function rowHaystack(dept, r){
|
|
const parts = [
|
|
r.id, r.codigo, r.cliente, r.nombre, r.instrucciones,
|
|
r.fechaSolicitud, r.fecha_solicitud,
|
|
];
|
|
if (dept === 'adm') parts.push(r.institucion);
|
|
if (dept === 'corp') parts.push(r.tipoDocumento);
|
|
if (dept === 'mig') parts.push(r.tipoTramite, r.docCompleta, r.permisoTrabajo, r.dependientes, r.fechaVencimiento);
|
|
if (dept === 'lit') parts.push(r.tipoProceso, r.juzgado, r.circuito, r.esfera, r.instancia, r.docCompleta, r.incidentes, r.amparo, r.fechaTermino);
|
|
(r.involucrados || []).forEach(u => parts.push(u.name, u.email));
|
|
return parts.filter(Boolean).join(' ').toLowerCase();
|
|
}
|
|
|
|
function computeFiltered(dept){
|
|
const rows = data[dept];
|
|
const f = filterState[dept];
|
|
const q = f.search.toLowerCase();
|
|
const filtered = rows.filter(r => {
|
|
if (q && !rowHaystack(dept, r).includes(q)) return false;
|
|
if (f.status && r.estatus !== f.status) return false;
|
|
if (f.abog && r.abogado !== f.abog) return false;
|
|
if (dept === 'lit' && f.esfera && r.esfera !== f.esfera) return false;
|
|
return true;
|
|
});
|
|
window.__tramFiltered = window.__tramFiltered || {};
|
|
window.__tramFiltered[dept] = filtered;
|
|
return filtered;
|
|
}
|
|
|
|
// Reconstruye toda la pestaña. Se usa solo cuando cambia el conjunto de datos
|
|
// (carga inicial, guardar/borrar, cambio de tab). NO se llama al teclear.
|
|
function renderPanel(dept){
|
|
const panel = document.getElementById('panel-'+dept);
|
|
const rows = data[dept];
|
|
const statuses = STATUS_MAP[dept];
|
|
const f = filterState[dept];
|
|
|
|
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;
|
|
const filtered = computeFiltered(dept);
|
|
|
|
panel.innerHTML=`
|
|
<div class="counts" id="counts-${dept}">
|
|
<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..." value="${esc(f.search)}">
|
|
<select class="f-status">
|
|
<option value="">— Estatus —</option>
|
|
${statuses.map(s=>`<option${f.status===s?' selected':''}>${s}</option>`).join('')}
|
|
</select>
|
|
<select class="f-abog">
|
|
<option value="">— Abogado —</option>
|
|
${ABOGADOS.map(a=>`<option${f.abog===a?' selected':''}>${a}</option>`).join('')}
|
|
</select>
|
|
${dept==='lit'?`<select class="f-esfera">
|
|
<option value="">— Esfera —</option>
|
|
<option${f.esfera==='Civil'?' selected':''}>Civil</option><option${f.esfera==='Penal'?' selected':''}>Penal</option>
|
|
</select>`:''}
|
|
${CAN_CREATE ? `<button class="btn btn-primary" onclick="openModal('${dept}',null)">+ Nuevo</button>` : ''}
|
|
${DonnaExport.buttonHtml('export-wrap-'+dept)}
|
|
</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>`;
|
|
DonnaExport.attach('export-wrap-'+dept, {
|
|
onExcel: () => tramExportExcel(dept),
|
|
onPDF: () => tramExportPDF(dept),
|
|
});
|
|
|
|
// Handlers de filtro: al cambiar solo repintan la tabla, no el toolbar.
|
|
// Así el input de búsqueda conserva foco y posición del cursor al tipear.
|
|
const searchEl = panel.querySelector('.search-input');
|
|
if (searchEl) searchEl.addEventListener('input', e => {
|
|
filterState[dept].search = e.target.value;
|
|
renderPanelBody(dept);
|
|
});
|
|
const statusEl = panel.querySelector('.f-status');
|
|
if (statusEl) statusEl.addEventListener('change', e => {
|
|
filterState[dept].status = e.target.value;
|
|
renderPanelBody(dept);
|
|
});
|
|
const abogEl = panel.querySelector('.f-abog');
|
|
if (abogEl) abogEl.addEventListener('change', e => {
|
|
filterState[dept].abog = e.target.value;
|
|
renderPanelBody(dept);
|
|
});
|
|
const esferaEl = panel.querySelector('.f-esfera');
|
|
if (esferaEl) esferaEl.addEventListener('change', e => {
|
|
filterState[dept].esfera = e.target.value;
|
|
renderPanelBody(dept);
|
|
});
|
|
}
|
|
|
|
// Solo actualiza contadores y tbody — deja el toolbar intacto.
|
|
function renderPanelBody(dept){
|
|
const rows = data[dept];
|
|
const filtered = computeFiltered(dept);
|
|
const counts = document.getElementById('counts-'+dept);
|
|
if (counts) {
|
|
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;
|
|
counts.innerHTML =
|
|
`<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>`;
|
|
}
|
|
const tbody = document.getElementById('tbody-'+dept);
|
|
if (tbody) {
|
|
tbody.innerHTML = 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>`;
|
|
}
|
|
}
|
|
|
|
// ── EXPORT (Trámites) ────────────────────────────────────────────────────
|
|
const TRAM_HEADERS = {
|
|
adm: ['Código','Institución','Nombre Trámite','Cliente','Abogado','Involucrados','Estatus','Fecha Solicitud','Horas'],
|
|
corp: ['Código','Tipo Documento','Nombre Trámite','Cliente','Abogado','Involucrados','Estatus','Fecha Solicitud','Horas'],
|
|
mig: ['Código','Tipo Trámite','Cliente','Abogado','Involucrados','Doc. Completa','Permiso Trabajo','Dependientes','Estatus','Fecha Solicitud','Vencimiento','Horas'],
|
|
lit: ['Código','Proceso','Juzgado','Cliente','Abogado','Involucrados','Esfera','Instancia','Doc. Completa','Incidentes','Amparo','Estatus','Fecha Solicitud','Término','Horas'],
|
|
};
|
|
function fmtDateOnly(s){ return s ? String(s).substring(0,10) : ''; }
|
|
function invsCSV(invs){ return (invs||[]).map(u => u.name || u.email).join(', '); }
|
|
function tramRowToArray(dept, r){
|
|
const inv = invsCSV(r.involucrados);
|
|
if (dept === 'adm') return [r.codigo, r.institucion||'', r.nombre||'', r.cliente||'', r.abogado||'', inv, r.estatus||'', fmtDateOnly(r.fecha_solicitud), totalHours(r)];
|
|
if (dept === 'corp')return [r.codigo, r.tipo_documento||'', r.nombre||'', r.cliente||'', r.abogado||'', inv, r.estatus||'', fmtDateOnly(r.fecha_solicitud), totalHours(r)];
|
|
if (dept === 'mig') return [r.codigo, r.tipo_tramite||'', r.cliente||'', r.abogado||'', inv, r.doc_completa||'', r.permiso_trabajo||'', r.dependientes||'', r.estatus||'', fmtDateOnly(r.fecha_solicitud), fmtDateOnly(r.fecha_vencimiento), totalHours(r)];
|
|
if (dept === 'lit') return [r.codigo, r.tipo_proceso||'', r.juzgado||'', r.cliente||'', r.abogado||'', inv, r.esfera||'', r.instancia||'', r.doc_completa||'', r.incidentes||'', r.amparo||'', r.estatus||'', fmtDateOnly(r.fecha_solicitud), fmtDateOnly(r.fecha_termino), totalHours(r)];
|
|
}
|
|
function tramExportExcel(dept){
|
|
const rows = (window.__tramFiltered && window.__tramFiltered[dept]) || data[dept] || [];
|
|
DonnaExport.toExcel({
|
|
filename: 'Tramites_'+dept.toUpperCase()+'_AAL',
|
|
sheetName: DEPT_LABELS[dept] || dept,
|
|
headers: TRAM_HEADERS[dept],
|
|
rows: rows.map(r => tramRowToArray(dept, r)),
|
|
onEmpty: () => toast('No hay datos para exportar','error'),
|
|
});
|
|
}
|
|
function tramExportPDF(dept){
|
|
const rows = (window.__tramFiltered && window.__tramFiltered[dept]) || data[dept] || [];
|
|
// En PDF excluye Involucrados (lista larga) para que quepa
|
|
const cols = TRAM_HEADERS[dept].map((_,i) => i).filter(i => TRAM_HEADERS[dept][i] !== 'Involucrados');
|
|
DonnaExport.toPDF({
|
|
filename: 'Tramites_'+dept.toUpperCase()+'_AAL',
|
|
title: 'Control de Trámites Legales · ' + (DEPT_LABELS[dept] || dept),
|
|
headers: TRAM_HEADERS[dept],
|
|
rows: rows.map(r => tramRowToArray(dept, r)),
|
|
pdfCols: cols,
|
|
onEmpty: () => toast('No hay datos para exportar','error'),
|
|
});
|
|
}
|
|
|
|
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>Involucrados</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>Involucrados</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>Involucrados</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>Involucrados</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 initialsOf(text){
|
|
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
|
|
return ((parts[0]||'').charAt(0)+(parts[1]||'').charAt(0)).toUpperCase()||'?';
|
|
}
|
|
function involucradosCell(invs){
|
|
if(!invs || !invs.length) return '<span style="color:var(--gray)">—</span>';
|
|
const top = invs.slice(0,3).map(u =>
|
|
`<span class="inv-avatar" title="${esc(u.name||u.email)} (${esc(u.email)})">${esc(initialsOf(u.name||u.email))}</span>`
|
|
).join('');
|
|
const more = invs.length>3 ? `<span class="inv-more">+${invs.length-3}</span>` : '';
|
|
return `<span class="inv-stack">${top}${more}</span>`;
|
|
}
|
|
|
|
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>`:'—';}
|
|
|
|
// 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){
|
|
if(!r.hours||!r.hours.length) return '0';
|
|
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(currentValue){
|
|
let out = '';
|
|
const cur = currentValue !== undefined ? parseFloat(currentValue) : null;
|
|
for (let q = 1; q <= 48; q++) {
|
|
const value = q * 0.25;
|
|
const sel = (cur !== null && Math.abs(value - cur) < 0.001) ? ' selected' : '';
|
|
out += `<option value="${value}"${sel}>${fmtHours(value)}</option>`;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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='';
|
|
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)}</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)}</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>${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 actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> ${delBtn}</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>`:'';
|
|
// Cualquiera que pueda ver este trámite puede editar/eliminar horas
|
|
// (la API valida lo mismo: canView del trámite).
|
|
const hrs=(r.hours||[]).map(h => {
|
|
const actions = `<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()">
|
|
${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>Involucrados:</b> ${(r.involucrados&&r.involucrados.length)?r.involucrados.map(u=>esc(u.name||u.email)).join(', '):'<span style="color:var(--gray)">—</span>'}</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}</div>
|
|
${(()=>{
|
|
// Opciones del dropdown de Horas: abogado solicitante + todos los involucrados,
|
|
// deduplicados por nombre. Si el trámite no tiene ni solicitante ni involucrados,
|
|
// el dropdown queda deshabilitado.
|
|
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 list = [...opts];
|
|
const enabled = list.length > 0;
|
|
return `<div class="add-hours" onclick="event.stopPropagation()">
|
|
${enabled
|
|
? `<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-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'}>
|
|
<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>
|
|
</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'); }
|
|
}
|
|
|
|
// ── 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);
|
|
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;
|
|
// Inicializar involucrados: si edita, los actuales; si crea, auto-incluir al usuario actual
|
|
const initialIds = r ? (r.involucrados||[]).map(u => Number(u.id))
|
|
: (MY_ID > 0 ? [MY_ID] : []);
|
|
currentInvIds = new Set(initialIds);
|
|
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');
|
|
// Hook the change handler on the picker dropdown (rendered as part of buildForm)
|
|
setTimeout(()=>{
|
|
const sel = document.getElementById('inv-add');
|
|
if(sel) sel.addEventListener('change', e => addInv(e.target));
|
|
renderInvChips();
|
|
}, 30);
|
|
}
|
|
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 buildInvolucradosPicker(){
|
|
return `<div class="inv-picker">
|
|
<div class="inv-chips" id="inv-chips"></div>
|
|
<select id="inv-add"><option value="">+ Agregar involucrado…</option></select>
|
|
</div>`;
|
|
}
|
|
function renderInvChips(){
|
|
const chips = document.getElementById('inv-chips');
|
|
const sel = document.getElementById('inv-add');
|
|
if(!chips || !sel) return;
|
|
const ids = [...currentInvIds];
|
|
chips.innerHTML = ids.map(id => {
|
|
const u = USERS.find(x => x.id === id);
|
|
if(!u) return '';
|
|
return `<span class="inv-chip" title="${esc(u.email)}">${esc(u.name||u.email)}<button type="button" onclick="removeInv(${id})">✕</button></span>`;
|
|
}).join('');
|
|
const available = USERS.filter(u => !currentInvIds.has(u.id));
|
|
sel.innerHTML = '<option value="">+ Agregar involucrado…</option>' +
|
|
available.map(u => `<option value="${u.id}">${esc(u.name||u.email)} (${esc(u.email)})</option>`).join('');
|
|
}
|
|
function addInv(selEl){
|
|
const id = parseInt(selEl.value, 10);
|
|
if(!id) return;
|
|
currentInvIds.add(id);
|
|
selEl.value='';
|
|
renderInvChips();
|
|
}
|
|
function removeInv(id){
|
|
currentInvIds.delete(parseInt(id, 10));
|
|
renderInvChips();
|
|
}
|
|
|
|
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');
|
|
const invPicker=buildInvolucradosPicker();
|
|
|
|
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)}
|
|
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
|
|
|
|
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)}
|
|
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
|
|
|
|
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)}
|
|
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
|
|
|
|
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)}
|
|
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
|
|
}
|
|
|
|
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');
|
|
}
|
|
// Involucrados: array de user_ids; el backend valida que existan y estén activos
|
|
payload.involucrados = [...currentInvIds];
|
|
|
|
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>
|