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:
@@ -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> </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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
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>
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function stripTime(d){ const o=new Date(d); o.setHours(0,0,0,0); return o; }
|
||||
|
||||
+20
-1
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user