feat(perms): permisología completa con 3 roles (Socio/Abogado/Usuario) + Panel de Control

ESQUEMA (migraciones 005 + 006)
* users: agrega role ENUM('socio','abogado','usuario'), apellido, activo, orden
* Seed: cabrego@aal-law.com como socio + 15 placeholders por apellido para
  poblar el dropdown "Abogado Solicitante" hasta que los reales se logueen.
* tramites_involucrados: tabla N:M (tramite_id, user_id) con CASCADE delete.

AUTH (src/Auth.php)
* userRow() cachea el row del usuario actual por request.
* role(), isSocio(), isAbogado(), canCreate(), canDelete(), requireSocio().
* completeLogin() retorna bool: false si user.activo=0 → callback muestra
  pantalla "cuenta deshabilitada" en lugar de loguear.

BOOTSTRAP (src/Bootstrap.php + src/Lookups.php)
* Bootstrap::inject(view) reemplaza readfile en los wrappers; inyecta
  <script>window.DONNA={me,name,role,isSocio,isAbogado,canCreate,canDelete,
  abogados,users}</script> antes de </head> para que el frontend tenga
  contexto sin requests extra.

TRAMITES — visibilidad + involucrados
* api.php: list filtra por (socio | creador | abogado solicitante mapped via
  users.apellido | involucrado) excepto socios que ven todo. Todas las
  mutaciones validan canView(). create/delete validan rol. Persiste
  involucrados vía syncInvolucrados().
* tramites.html: nueva columna "Involucrados" después de "Abogado", chip
  picker multi-select en el modal, gear icon (oculto a non-socios), oculta
  botones "+ Nuevo" y ✕ para usuarios sin permisos. Auto-incluye al
  creador como involucrado por defecto.
* Calendario y demás apps consumen el API filtrado → heredan la
  visibilidad sin código nuevo.

PANEL DE CONTROL (/admin/)
* index.php: requireSocio() + Bootstrap injection.
* api.php: list_users, create_user, update_user. Salvaguardas: el socio
  no puede demoverse ni desactivarse a sí mismo.
* admin.html: tabla unificada de usuarios con filtros, modal CRUD,
  resaltado de fila propia, placeholders identificados visualmente.

GEAR ICON
* Agregado en escrituras.html, tramites.html, calendario.html con
  display:none por defecto; JS lo revela si window.DONNA.isSocio.
* Link a /admin/.
This commit is contained in:
Soporte ZH Consultores
2026-06-04 23:14:07 +00:00
parent cd9dae98bb
commit 2db8f23ba5
16 changed files with 1088 additions and 37 deletions
+110 -15
View File
@@ -33,6 +33,19 @@ header{background:var(--navy);border-bottom:3px solid var(--gold);padding:0 24px
.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{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{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.btn-gear 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);}
@@ -148,6 +161,9 @@ tr.expanded>td{background:#fffdf5;}
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">Cargando…</span>
</div>
<a class="btn-gear" href="/admin/" id="btn-gear" title="Panel de Control" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a>
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
</div>
</header>
@@ -181,7 +197,21 @@ tr.expanded>td{background:#fffdf5;}
<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 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();
// Mostrar gear icon si el usuario es socio
if (IS_SOCIO) {
document.addEventListener('DOMContentLoaded', () => {
const g = document.getElementById('btn-gear');
if (g) g.style.display = 'inline-flex';
});
}
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'],
@@ -371,7 +401,7 @@ function 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>
${CAN_CREATE ? `<button class="btn btn-primary" onclick="openModal('${dept}',null)">+ Nuevo</button>` : ''}
</div>
<div class="tbl-wrap">
<table>
@@ -385,10 +415,23 @@ function renderPanel(dept){
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>`;
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');}
@@ -410,15 +453,17 @@ function venceAlert(fecha){
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>`;
const invCell = `<td>${involucradosCell(r.involucrados)}</td>`;
if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`;
if(dept==='corp') cells=`<td>${esc(r.tipoDocumento)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`;
if(dept==='mig') cells=`<td>${esc(r.tipoTramite)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.permisoTrabajo)}</td><td>${yesnoBadge(r.dependientes)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaVencimiento)||'—'}${venceAlert(r.fechaVencimiento)}</td><td>${totalHours(r)} h</td>`;
if(dept==='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>`;
cells=`<td><b>${esc(r.tipoProceso)||'—'}</b><br><small>${esc(r.juzgado)||'—'} · ${esc(r.circuito)||'—'}</small></td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td>${invCell}<td>${esfera?`<span class="badge b-${esfera.toLowerCase()}">${esfera}</span>`:'—'}</td><td>${inst?`<span class="badge b-${statusSlug(inst)}">${inst}</span>`:'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.incidentes)}</td><td>${yesnoBadge(r.amparo)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaTermino)||'—'}${venceAlert(r.fechaTermino)}</td><td>${totalHours(r)} h</td>`;
}
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>`;
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>
@@ -466,6 +511,7 @@ function buildDetail(dept,r){
${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>
@@ -575,9 +621,19 @@ 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;}
@@ -589,11 +645,44 @@ function selBlank(arr,val,id){
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á'))}
@@ -603,7 +692,8 @@ function buildForm(dept,r){
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
${fld('Estatus',estatusSel)}`;
${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...'))}
@@ -613,7 +703,8 @@ function buildForm(dept,r){
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
${fld('Estatus',estatusSel)}`;
${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...'))}
@@ -630,7 +721,8 @@ function buildForm(dept,r){
${fld('Dependientes',selBlank(SI_NO,r?.dependientes,'f-dependientes'))}
${fld('Fecha de Vencimiento',inp('f-fechaVencimiento',r?.fechaVencimiento,'date'))}
</div>
${fld('Estatus',estatusSel)}`;
${fld('Estatus',estatusSel)}
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
if(dept==='lit') return `
<div class="form-row">
@@ -655,7 +747,8 @@ function buildForm(dept,r){
${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('Estatus',estatusSel)}
${fld('Involucrados (pueden ver y editar este trámite)', invPicker)}`;
}
function g(id){const el=document.getElementById(id);return el?el.value:'';}
@@ -689,6 +782,8 @@ async function saveModal(){
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;