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
+434
View File
@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panel de Control — Donna AALN</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@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--navy:#1B3A6B; --navy-d:#112446; --navy-l:#2D5090;
--gold:#C9A84C; --gold-l:#E8C96A; --gold-d:#9E7C2E;
--off:#F7F6F3;
--gray-100:#EEEDE9; --gray-200:#D6D4CC; --gray-400:#9B9A96;
--gray-700:#4A4946; --gray-900:#1A1916;
--red:#c0392b; --orange:#e67e22; --green:#27ae60;
--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.06);
}
*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'DM Sans',system-ui,sans-serif; color:var(--gray-900); background:var(--off); font-size:13.5px; }
a { color:inherit; text-decoration:none; }
button { font-family:inherit; cursor:pointer; }
code { font-family:ui-monospace,monospace; background:var(--gray-100); padding:1px 5px; border-radius:3px; font-size:11.5px; }
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; }
.header-left { display:flex; align-items:center; gap:14px; }
.btn-back { display:inline-flex; align-items:center; gap:6px;
background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18);
color:rgba(255,255,255,.9); padding:6px 12px 6px 9px; border-radius:8px;
font-size:12px; 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',serif; font-size:15px; color:var(--navy); }
.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,.85); }
.header-right { display:flex; align-items:center; gap:16px; }
.user-chip { display:flex; align-items:center; gap:8px; background:rgba(255,255,255,.1);
border:1px solid rgba(255,255,255,.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; }
.user-name { font-size:12px; color:rgba(255,255,255,.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,.2); border-radius:6px;
background:transparent; color:rgba(255,255,255,.7); transition:all .15s; }
.btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; }
.intro { max-width:1100px; margin:24px auto 0; padding:0 24px; }
.intro h1 { font-family:'DM Serif Display',serif; font-size:30px; color:var(--navy); font-weight:400; margin-bottom:6px; }
.intro p { font-size:13.5px; color:var(--gray-700); line-height:1.55; max-width:820px; }
.toolbar { max-width:1100px; margin:18px auto 0; padding:0 24px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.toolbar input, .toolbar select { padding:7px 11px; border:1px solid var(--gray-200); border-radius:8px; font-family:inherit; font-size:12.5px; background:#fff; }
.toolbar input { flex:1; min-width:200px; max-width:320px; }
.btn-primary { padding:7px 16px; background:var(--navy); color:#fff; border:none; border-radius:8px;
font-size:13px; font-weight:500; transition:all .15s; }
.btn-primary:hover { background:var(--navy-d); }
.btn-secondary { padding:7px 14px; background:transparent; border:1px solid var(--gray-200); color:var(--gray-700); border-radius:8px; font-size:13px; }
.btn-secondary:hover { background:var(--gray-100); }
.tbl-card { max-width:1100px; margin:18px auto 32px; padding:0 24px; }
.tbl-wrap { background:#fff; border:1px solid var(--gray-100); border-radius:12px; overflow:hidden; box-shadow:var(--shadow); }
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th { background:var(--navy); color:rgba(255,255,255,.9); padding:10px 12px; text-align:left;
font-size:10.5px; font-weight:600; text-transform:uppercase; letter-spacing:.07em;
white-space:nowrap; border-right:1px solid rgba(255,255,255,.06); }
th:last-child { border-right:none; }
td { padding:9px 12px; border-bottom:1px solid var(--gray-100); color:var(--gray-700); vertical-align:middle; }
tr:last-child td { border-bottom:none; }
tr:hover td { background:#F9FAFC; }
tr.inactive td { color:var(--gray-400); background:#FAFAF7; font-style:italic; }
tr.is-me td { background:#FFFBEB; font-weight:500; }
.role-pill { display:inline-block; padding:2px 9px; border-radius:14px; font-size:10.5px; font-weight:700;
letter-spacing:.04em; text-transform:uppercase; }
.role-socio { background:#FCE4EC; color:#880E4F; }
.role-abogado { background:#E1F5EE; color:#085041; }
.role-usuario { background:#EEEDE9; color:#5F5E5A; }
.activo-pill { display:inline-block; padding:2px 8px; border-radius:14px; font-size:10.5px; font-weight:600; }
.activo-1 { background:#D4EDDA; color:#155724; }
.activo-0 { background:#F5C2C7; color:#842029; }
.placeholder { font-style:italic; color:var(--gray-400); font-size:11px; }
.actions { display:flex; gap:6px; }
.btn-icon { width:28px; height:28px; border:1px solid var(--gray-200); border-radius:6px; background:#fff;
color:var(--gray-700); display:inline-flex; align-items:center; justify-content:center; transition:all .15s; }
.btn-icon:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); }
.last-login { font-size:11px; color:var(--gray-400); white-space:nowrap; }
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(17,36,70,.6);
z-index:1000; align-items:center; justify-content:center; padding:24px; }
.modal-overlay.open { display:flex; }
.modal { background:#fff; border-radius:14px; width:100%; max-width:520px; max-height:90vh;
overflow:hidden; display:flex; flex-direction:column; box-shadow:0 20px 60px rgba(0,0,0,.3); }
.modal-head { background:var(--navy); color:#fff; padding:16px 22px; display:flex;
justify-content:space-between; align-items:center; border-bottom:2px solid var(--gold); }
.modal-head h3 { font-family:'DM Serif Display',serif; font-size:18px; font-weight:400; margin:0; }
.modal-head .close { background:transparent; border:none; color:rgba(255,255,255,.7); font-size:22px; line-height:1; }
.modal-head .close:hover { color:#fff; }
.modal-body { padding:18px 22px; overflow-y:auto; flex:1; }
.fg { margin-bottom:14px; }
.fg label { display:block; font-size:10.5px; font-weight:600; color:var(--navy);
margin-bottom:5px; text-transform:uppercase; letter-spacing:.08em; }
.fg input, .fg select { width:100%; padding:8px 11px; border:1px solid var(--gray-200);
border-radius:8px; font-family:'DM Sans',sans-serif; font-size:13px; background:#fff; }
.fg input:focus, .fg select:focus { outline:none; border-color:var(--navy-l);
box-shadow:0 0 0 3px rgba(27,58,107,.10); }
.fg-hint { font-size:10.5px; color:var(--gray-400); margin-top:4px; line-height:1.4; }
.fg-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.fg-check { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--gray-700); cursor:pointer; }
.fg-check input { width:auto; margin:0; }
.modal-foot { padding:14px 22px; border-top:1px solid var(--gray-100); display:flex; justify-content:flex-end;
gap:10px; background:var(--off); }
.toast { position:fixed; bottom:24px; right:24px; background:var(--navy-d); color:#fff;
padding:11px 18px; border-radius:8px; font-size:12.5px; border-left:3px solid var(--gold);
box-shadow:0 4px 20px rgba(0,0,0,.2); transform:translateY(20px); opacity:0;
transition:all .25s; z-index:2000; }
.toast.show { transform:translateY(0); opacity:1; }
.toast.error { border-left-color:#ef5350; }
.empty { text-align:center; padding:40px; color:var(--gray-400); }
</style>
</head>
<body>
<header>
<div class="header-left">
<a class="btn-back" href="/" title="Volver al portal">
<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">AALN Law</span>
<span class="logo-tool">Panel de Control</span>
</div>
</div>
</div>
<div class="header-right">
<div class="user-chip">
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">…</span>
</div>
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
</div>
</header>
<div class="intro">
<h1>Usuarios y permisos</h1>
<p>Administra la lista unificada de usuarios del sistema. El campo <b>Apellido</b> es lo que aparece en el dropdown "Abogado Solicitante" (solo si el rol es <b>Socio</b> o <b>Abogado</b>). Los emails que empiezan con <code>placeholder-</code> son los apellidos sembrados al inicio para que los dropdowns no salieran vacíos; reemplázalos con los emails reales conforme los abogados se logueen.</p>
</div>
<div class="toolbar">
<input type="search" id="search" placeholder="Buscar por email, nombre o apellido…">
<select id="f-role">
<option value="">— Todos los roles —</option>
<option value="socio">Socio</option>
<option value="abogado">Abogado</option>
<option value="usuario">Usuario</option>
</select>
<select id="f-activo">
<option value="">— Activos e inactivos —</option>
<option value="1">Solo activos</option>
<option value="0">Solo inactivos</option>
</select>
<button class="btn-primary" onclick="openCreate()">+ Nuevo usuario</button>
</div>
<div class="tbl-card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Email</th>
<th>Nombre</th>
<th>Apellido</th>
<th>Rol</th>
<th>Activo</th>
<th>Orden</th>
<th>Último login</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" class="empty" style="display:none;">Sin usuarios que coincidan con los filtros.</div>
</div>
</div>
<div class="modal-overlay" id="modal" onclick="if(event.target.id==='modal')closeModal()">
<div class="modal">
<header class="modal-head">
<h3 id="modal-title">Nuevo usuario</h3>
<button class="close" onclick="closeModal()" title="Esc">✕</button>
</header>
<div class="modal-body">
<div class="fg">
<label>Email *</label>
<input type="email" id="f-email" placeholder="usuario@aal-law.com">
<div class="fg-hint" id="hint-email">El email debe coincidir exactamente con la cuenta de Mailcow que el usuario usa para entrar.</div>
</div>
<div class="fg">
<label>Nombre completo</label>
<input type="text" id="f-name" placeholder="Ej: Carlos Abrego">
</div>
<div class="fg-row">
<div class="fg">
<label>Apellido (dropdown)</label>
<input type="text" id="f-apellido" placeholder="Ej: Abrego">
<div class="fg-hint">Solo aparece en dropdowns si el rol es Socio o Abogado.</div>
</div>
<div class="fg">
<label>Rol</label>
<select id="f-role-edit">
<option value="usuario">Usuario (pasante / asistente)</option>
<option value="abogado">Abogado</option>
<option value="socio">Socio (modo Dios)</option>
</select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>Orden</label>
<input type="number" id="f-orden" value="0" step="1">
<div class="fg-hint">Para ordenar el dropdown manualmente.</div>
</div>
<div class="fg">
<label>&nbsp;</label>
<label class="fg-check"><input type="checkbox" id="f-activo" checked> Activo — puede entrar a Donna</label>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn-secondary" onclick="closeModal()">Cancelar</button>
<button class="btn-primary" id="btn-save" onclick="saveUser()">Guardar</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const ROLES = ['socio','abogado','usuario'];
const ROLE_LABEL = { socio:'Socio', abogado:'Abogado', usuario:'Usuario' };
const MY_EMAIL = (window.DONNA && window.DONNA.me) || '';
let users = [];
let editingId = null;
let authUser = { email: MY_EMAIL, name: (window.DONNA && window.DONNA.name) || '' };
function $(id){ return document.getElementById(id); }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function toast(msg, type='ok'){
const el=$('toast'); el.textContent=msg;
el.className='toast show'+(type==='error'?' error':'');
setTimeout(()=>el.className='toast'+(type==='error'?' error':''),3500);
}
function initials(text){
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
return ((parts[0]||'').charAt(0)+(parts[1]||'').charAt(0)).toUpperCase()||'?';
}
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:'short',year:'numeric'})
+' '+d.toLocaleTimeString('es-PA',{hour:'2-digit',minute:'2-digit'});
}
function setUserChip(){
$('user-avatar').textContent = initials(authUser.name || authUser.email);
$('user-name').textContent = authUser.name || authUser.email || '—';
$('user-name').title = authUser.email;
}
async function apiCall(method, action, body=null, query=''){
const url = '/admin/api.php?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'); }
if(res.status===401 && j.auth_required){
window.location.href = j.login_url || '/auth/login.php?return_to=/admin/';
return null;
}
if(!res.ok || !j.ok) throw new Error(j.error || ('HTTP '+res.status));
if(j.user) authUser = j.user;
return j.data;
}
async function loadUsers(){
try {
users = (await apiCall('GET','list_users')) || [];
setUserChip();
render();
} catch(e){
toast('Error al cargar usuarios: '+e.message, 'error');
}
}
function visibleUsers(){
const q = $('search').value.toLowerCase().trim();
const fR = $('f-role').value;
const fA = $('f-activo').value;
return users.filter(u => {
if (fR && u.role !== fR) return false;
if (fA !== '' && String(u.activo) !== fA) return false;
if (q) {
const blob = ((u.email||'')+' '+(u.name||'')+' '+(u.apellido||'')).toLowerCase();
if (!blob.includes(q)) return false;
}
return true;
});
}
function render(){
const tbody = $('tbody');
const list = visibleUsers();
if (!list.length) {
tbody.innerHTML = '';
$('empty').style.display = 'block';
return;
}
$('empty').style.display = 'none';
tbody.innerHTML = list.map(u => {
const isMe = u.email === MY_EMAIL;
const isPlaceholder = (u.email||'').startsWith('placeholder-');
const rowCls = (u.activo === 0 ? 'inactive' : '') + (isMe ? ' is-me' : '');
return `<tr class="${rowCls}">
<td><b>${esc(u.email)}</b>${isPlaceholder?' <span class="placeholder">(placeholder)</span>':''}${isMe?' <span style="color:var(--gold-d);font-size:10px;">(tú)</span>':''}</td>
<td>${esc(u.name) || '<span class="placeholder">—</span>'}</td>
<td>${esc(u.apellido) || '<span class="placeholder">—</span>'}</td>
<td><span class="role-pill role-${u.role}">${ROLE_LABEL[u.role] || u.role}</span></td>
<td><span class="activo-pill activo-${u.activo}">${u.activo ? 'Activo' : 'Inactivo'}</span></td>
<td>${u.orden}</td>
<td class="last-login">${u.last_login_at ? fmtDate(u.last_login_at) : '<span class="placeholder">Nunca</span>'}</td>
<td><div class="actions"><button class="btn-icon" title="Editar" onclick="openEdit(${u.id})">✎</button></div></td>
</tr>`;
}).join('');
}
function openCreate(){
editingId = null;
$('modal-title').textContent = 'Nuevo usuario';
$('f-email').value = ''; $('f-email').readOnly = false;
$('f-name').value = '';
$('f-apellido').value = '';
$('f-role-edit').value = 'usuario';
$('f-orden').value = '0';
$('f-activo').checked = true;
$('hint-email').textContent = 'El email debe coincidir exactamente con la cuenta de Mailcow que el usuario usa para entrar.';
$('modal').classList.add('open');
setTimeout(()=>$('f-email').focus(), 50);
}
function openEdit(id){
const u = users.find(x => x.id === id);
if (!u) return;
editingId = u.id;
$('modal-title').textContent = 'Editar usuario · ' + u.email;
$('f-email').value = u.email;
$('f-email').readOnly = false;
$('f-name').value = u.name || '';
$('f-apellido').value = u.apellido || '';
$('f-role-edit').value = u.role;
$('f-orden').value = u.orden;
$('f-activo').checked = u.activo === 1;
$('hint-email').textContent = (u.email||'').startsWith('placeholder-')
? 'Reemplaza este email placeholder por el email real del abogado.'
: 'Cambiar el email afecta la asociación con Mailcow OAuth.';
$('modal').classList.add('open');
setTimeout(()=>$('f-name').focus(), 50);
}
function closeModal(){
$('modal').classList.remove('open');
editingId = null;
}
async function saveUser(){
const payload = {
email: $('f-email').value.trim().toLowerCase(),
name: $('f-name').value.trim(),
apellido: $('f-apellido').value.trim(),
role: $('f-role-edit').value,
orden: parseInt($('f-orden').value, 10) || 0,
activo: $('f-activo').checked ? 1 : 0,
};
if (!payload.email) { toast('Email requerido','error'); $('f-email').focus(); return; }
const btn = $('btn-save');
btn.disabled = true;
try {
let saved;
if (editingId) {
payload._id = editingId;
saved = await apiCall('PUT','update_user', payload);
} else {
saved = await apiCall('POST','create_user', payload);
}
if (!saved) return;
const idx = users.findIndex(u => u.id === saved.id);
if (idx >= 0) users[idx] = saved; else users.unshift(saved);
closeModal();
render();
toast(editingId ? 'Usuario actualizado' : 'Usuario creado');
} catch(e){
toast('Error: '+e.message, 'error');
} finally {
btn.disabled = false;
}
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
['search','f-role','f-activo'].forEach(id => {
$(id).addEventListener('input', render);
$(id).addEventListener('change', render);
});
loadUsers();
</script>
</body>
</html>
+16
View File
@@ -54,6 +54,11 @@ header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0
.btn-logout { font-size:11px; padding:4px 10px; border:1px solid rgba(255,255,255,.2); border-radius:6px;
background:transparent; color:rgba(255,255,255,.7); transition:all .15s; }
.btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; }
.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; }
.cal-toolbar { background:#fff; border-bottom:1px solid var(--gray-100); padding:12px 24px;
display:flex; align-items:center; gap:14px; flex-wrap:wrap; box-shadow:var(--shadow); }
@@ -273,6 +278,9 @@ footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px;
<div class="user-avatar" id="user-avatar">?</div>
<span class="user-name" id="user-name">…</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>
@@ -363,6 +371,14 @@ let search = '';
let selected = null;
let authUser = { email:'', name:'' };
// Gear icon solo visible para socios (window.DONNA inyectado por Bootstrap)
if (window.DONNA && window.DONNA.isSocio) {
document.addEventListener('DOMContentLoaded', () => {
const g = document.getElementById('btn-gear');
if (g) g.style.display = 'inline-flex';
});
}
function $(id){ return document.getElementById(id); }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function stripTime(d){ const o=new Date(d); o.setHours(0,0,0,0); return o; }
+20 -1
View File
@@ -77,6 +77,15 @@ header {
transition:all .15s;
}
.btn-logout:hover { background:rgba(255,255,255,0.1); color:#fff; }
.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; }
/* BACK TO PORTAL */
.header-left { display:flex; align-items:center; gap:14px; }
@@ -460,6 +469,9 @@ tr.editing-row td textarea.editable {
<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>
<button class="btn-logout" onclick="doLogout()">Cerrar sesión</button>
</div>
</header>
@@ -601,7 +613,14 @@ tr.editing-row td textarea.editable {
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<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'];
// Gear icon visible solo para socios
if (window.DONNA && window.DONNA.isSocio) {
document.addEventListener('DOMContentLoaded', () => {
const g = document.getElementById('btn-gear');
if (g) g.style.display = 'inline-flex';
});
}
const ESTATUS = ['Solicitado','Recibido','En Proceso','Protocolizado','En Registro','Inscrito'];
const API = 'api.php';
const POLL_MS = 15000;
+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;