feat(vcard-api): modal Ver Usuario con tabs + rol select + roles acumulados

- Reemplaza drill-down inline de Usuarios por modal 'Ver' con tabs:
  General, Perfil, VCards, Wallet, Pagos, Logs, MiniWebs
- Añade columna Rol en tabla de usuarios con badge coloreado
- Edit Usuario: rol_id ahora es <select> con nombres reales (administrador/cliente)
- Roles se acumulan automáticamente al cargar usuarios (sin endpoint dedicado)
- Carga lazy de Pagos, Logs, MiniWebs dentro del modal de detalle
- VCards en detalle incluye URL con enlace directo

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Lizandro Guarnizo
2026-05-20 09:34:43 -05:00
co-authored by Copilot
parent 62e49b39ad
commit b43c144a82
+325 -190
View File
@@ -77,6 +77,7 @@
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Email</th>
<th class="py-2 px-3">Rol</th>
<th class="py-2 px-3">Plan</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 text-right">Acciones</th>
@@ -86,167 +87,32 @@
<template x-for="u in items" :key="u.id || u._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-medium" x-text="u.name || u.nombre || '—'"></td>
<td class="py-2 px-3 text-gray-500" x-text="u.email || '—'"></td>
<td class="py-2 px-3 text-gray-500 text-xs" x-text="u.email || '—'"></td>
<td class="py-2 px-3">
<span :class="(u.rol && u.rol.nombre === 'administrador') ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'"
class="text-xs px-2 py-0.5 rounded-full" x-text="(u.rol && u.rol.nombre) ? u.rol.nombre : '—'"></span>
</td>
<td class="py-2 px-3 text-xs" x-text="(u.plan && u.plan.nombre) ? u.plan.nombre : '—'"></td>
<td class="py-2 px-3">
<span :class="u.estado ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full" x-text="u.estado ? 'Activo' : 'Inactivo'"></span>
<span :class="(u.estado === true || u.estado === 'true') ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full"
x-text="(u.estado === true || u.estado === 'true') ? 'Activo' : 'Inactivo'"></span>
</td>
<td class="py-2 px-3 text-right">
<div class="flex justify-end gap-2 flex-wrap">
<button @click="openVerModal(u)" title="Ver detalle"
class="text-indigo-500 hover:text-indigo-700 text-xs px-2 py-1 border border-indigo-200 rounded">Ver</button>
<button @click="openEditUsuarioModal(u)" title="Editar"
class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-300 rounded">Editar</button>
<button x-show="!u.estado" @click="toggleUsuario(u, 'activar')" title="Activar"
<button x-show="!(u.estado === true || u.estado === 'true')" @click="toggleUsuario(u, 'activar')"
class="text-green-500 hover:text-green-700 text-xs px-2 py-1 border border-green-300 rounded">Activar</button>
<button x-show="u.estado" @click="toggleUsuario(u, 'desactivar')" title="Desactivar"
<button x-show="u.estado === true || u.estado === 'true'" @click="toggleUsuario(u, 'desactivar')"
class="text-red-400 hover:text-red-600 text-xs px-2 py-1 border border-red-200 rounded">Desactivar</button>
<button @click="openMembresiaModal(u)" title="Membresía"
<button @click="openMembresiaModal(u)"
class="text-blue-500 hover:text-blue-700 text-xs px-2 py-1 border border-blue-200 rounded">Membresía</button>
<button @click="openDrillDown(u)" title="Ver detalle"
class="text-purple-500 hover:text-purple-700 text-xs px-2 py-1 border border-purple-200 rounded">⊕ Ver</button>
</div>
</td>
</tr>
<!-- Fila drill-down expandible -->
<tr x-show="drillDownUserId === (u.id || u._id)" class="bg-purple-50">
<td colspan="5" class="px-4 py-3">
<div class="flex gap-2 mb-3 flex-wrap">
<button @click="loadDrillDown('vcards')"
:class="drillDownSection === 'vcards' ? 'bg-purple-600 text-white' : 'bg-white text-purple-600 border border-purple-300'"
class="text-xs px-3 py-1 rounded-full">VCards</button>
<button @click="loadDrillDown('pagos')"
:class="drillDownSection === 'pagos' ? 'bg-purple-600 text-white' : 'bg-white text-purple-600 border border-purple-300'"
class="text-xs px-3 py-1 rounded-full">Pagos</button>
<button @click="loadDrillDown('transacciones')"
:class="drillDownSection === 'transacciones' ? 'bg-purple-600 text-white' : 'bg-white text-purple-600 border border-purple-300'"
class="text-xs px-3 py-1 rounded-full">Transacciones</button>
<button @click="loadDrillDown('logs')"
:class="drillDownSection === 'logs' ? 'bg-purple-600 text-white' : 'bg-white text-purple-600 border border-purple-300'"
class="text-xs px-3 py-1 rounded-full">Logs</button>
<button @click="loadDrillDown('miniwebs')"
:class="drillDownSection === 'miniwebs' ? 'bg-purple-600 text-white' : 'bg-white text-purple-600 border border-purple-300'"
class="text-xs px-3 py-1 rounded-full">MiniWebs</button>
<button @click="closeDrillDown()" class="ml-auto text-xs text-gray-400 hover:text-gray-600">✕ Cerrar</button>
</div>
<div x-show="drillDownLoading" class="text-xs text-gray-400">Cargando...</div>
<div x-show="drillDownError" class="text-xs text-red-500" x-text="drillDownError"></div>
<div x-show="!drillDownLoading && drillDownItems.length === 0 && !drillDownError" class="text-xs text-gray-400">Sin registros.</div>
<!-- VCards del usuario -->
<template x-if="drillDownSection === 'vcards' && drillDownItems.length > 0">
<div class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b"><tr>
<th class="py-1 px-2">Nombre</th><th class="py-1 px-2">Empresa</th>
<th class="py-1 px-2">Email</th><th class="py-1 px-2">Estado</th>
</tr></thead>
<tbody>
<template x-for="r in drillDownItems" :key="r.id || r._id">
<tr class="border-b border-purple-100">
<td class="py-1 px-2 font-medium" x-text="r.nombre || '—'"></td>
<td class="py-1 px-2 text-gray-500" x-text="r.empresa || '—'"></td>
<td class="py-1 px-2 text-gray-500" x-text="r.email || '—'"></td>
<td class="py-1 px-2">
<span :class="r.estado ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="px-2 py-0.5 rounded-full" x-text="r.estado ? 'Activa' : 'Inactiva'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- Pagos del usuario -->
<template x-if="drillDownSection === 'pagos' && drillDownItems.length > 0">
<div class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b"><tr>
<th class="py-1 px-2">Referencia</th><th class="py-1 px-2">Monto</th>
<th class="py-1 px-2">Estado</th><th class="py-1 px-2">Fecha</th>
</tr></thead>
<tbody>
<template x-for="r in drillDownItems" :key="r.id || r._id">
<tr class="border-b border-purple-100">
<td class="py-1 px-2 font-mono" x-text="r.referencia || r.reference || '—'"></td>
<td class="py-1 px-2 font-medium" x-text="(r.moneda||'') + ' ' + (r.monto || r.amount || '—')"></td>
<td class="py-1 px-2">
<span :class="{'bg-green-100 text-green-700': r.estado==='completado','bg-yellow-100 text-yellow-700': r.estado==='pendiente','bg-red-100 text-red-700': r.estado==='fallido'}"
class="px-2 py-0.5 rounded-full" x-text="r.estado || '—'"></span>
</td>
<td class="py-1 px-2 text-gray-400" x-text="r.created_at ? r.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- Transacciones del usuario -->
<template x-if="drillDownSection === 'transacciones' && drillDownItems.length > 0">
<div class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b"><tr>
<th class="py-1 px-2">Tipo</th><th class="py-1 px-2">Monto</th>
<th class="py-1 px-2">Estado</th><th class="py-1 px-2">Descripción</th><th class="py-1 px-2">Fecha</th>
</tr></thead>
<tbody>
<template x-for="r in drillDownItems" :key="r.id || r._id">
<tr class="border-b border-purple-100">
<td class="py-1 px-2 font-mono bg-gray-100 rounded text-gray-700" x-text="r.type || '—'"></td>
<td class="py-1 px-2 font-medium" x-text="r.amount || '—'"></td>
<td class="py-1 px-2">
<span :class="{'bg-green-100 text-green-700': r.status==='completed','bg-yellow-100 text-yellow-700': r.status==='pending','bg-red-100 text-red-700': r.status==='failed'}"
class="px-2 py-0.5 rounded-full" x-text="r.status || '—'"></span>
</td>
<td class="py-1 px-2 text-gray-500 max-w-xs truncate" x-text="r.description || '—'"></td>
<td class="py-1 px-2 text-gray-400" x-text="r.created_at ? r.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- Logs del usuario -->
<template x-if="drillDownSection === 'logs' && drillDownItems.length > 0">
<div class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b"><tr>
<th class="py-1 px-2">Tipo</th><th class="py-1 px-2">Descripción</th><th class="py-1 px-2">Fecha</th>
</tr></thead>
<tbody>
<template x-for="r in drillDownItems" :key="r.id || r._id">
<tr class="border-b border-purple-100">
<td class="py-1 px-2 font-mono" x-text="r.type || r.tipo || '—'"></td>
<td class="py-1 px-2 text-gray-500 max-w-xs truncate" x-text="r.description || r.descripcion || '—'"></td>
<td class="py-1 px-2 text-gray-400" x-text="r.created_at ? r.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- MiniWebs del usuario -->
<template x-if="drillDownSection === 'miniwebs' && drillDownItems.length > 0">
<div class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b"><tr>
<th class="py-1 px-2">Título</th><th class="py-1 px-2">VCard vinculada</th><th class="py-1 px-2">Estado</th>
</tr></thead>
<tbody>
<template x-for="r in drillDownItems" :key="r.id || r._id">
<tr class="border-b border-purple-100">
<td class="py-1 px-2 font-medium" x-text="r.titulo || r.title || '—'"></td>
<td class="py-1 px-2 text-gray-500" x-text="(r.vcard && r.vcard.nombre) ? r.vcard.nombre : '—'"></td>
<td class="py-1 px-2">
<span :class="r.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="px-2 py-0.5 rounded-full" x-text="r.activo ? 'Activa' : 'Inactiva'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</td>
</tr>
</template>
</tbody>
</table>
@@ -582,6 +448,252 @@
</div>
</div>
<!-- ─── Modal: Ver Usuario ───────────────────────────────────────────── -->
<div x-show="verModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="flex justify-between items-start p-5 border-b shrink-0">
<div>
<h2 class="text-lg font-semibold" x-text="verData ? (verData.name || verData.nombre || '—') : 'Cargando...'"></h2>
<p class="text-xs text-gray-500 mt-0.5" x-text="verData ? (verData.email || '') : ''"></p>
<div class="mt-1 flex gap-2 flex-wrap">
<template x-if="verData">
<span :class="(verData.estado === true || verData.estado === 'true') ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full"
x-text="(verData.estado === true || verData.estado === 'true') ? 'Activo' : 'Inactivo'"></span>
</template>
<template x-if="verData && verData.rol">
<span :class="verData.rol.nombre === 'administrador' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'"
class="text-xs px-2 py-0.5 rounded-full" x-text="verData.rol.nombre || '—'"></span>
</template>
</div>
</div>
<button @click="verModal = false" class="text-gray-400 hover:text-gray-600 text-xl shrink-0"></button>
</div>
<!-- Tabs -->
<div class="border-b px-5 flex gap-1 overflow-x-auto shrink-0">
<template x-for="tab in ['general','perfil','vcards','wallet','pagos','logs','miniwebs']" :key="tab">
<button @click="verTab = tab; loadVerSection(tab)"
:class="verTab === tab ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'"
class="text-xs py-2 px-3 whitespace-nowrap capitalize">
<span x-text="tab.charAt(0).toUpperCase() + tab.slice(1)"></span>
</button>
</template>
</div>
<!-- Content -->
<div class="overflow-y-auto flex-1 p-5">
<div x-show="verLoading" class="text-sm text-gray-400 text-center py-8">Cargando...</div>
<div x-show="verError && !verLoading" class="text-sm text-red-500" x-text="verError"></div>
<!-- Tab General -->
<template x-if="!verLoading && verTab === 'general' && verData">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Nombre</span><span class="font-medium" x-text="verData.name || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Email</span><span x-text="verData.email || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Rol</span>
<span :class="verData.rol && verData.rol.nombre === 'administrador' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'"
class="text-xs px-2 py-0.5 rounded-full" x-text="(verData.rol && verData.rol.nombre) ? verData.rol.nombre : (verData.rol_id || '—')"></span>
</div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Plan</span>
<span class="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 rounded-full" x-text="(verData.plan && verData.plan.nombre) ? verData.plan.nombre : (verData.plan_id || '—')"></span>
</div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Estado</span>
<span :class="(verData.estado === true || verData.estado === 'true') ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full"
x-text="(verData.estado === true || verData.estado === 'true') ? 'Activo' : 'Inactivo'"></span>
</div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Premium</span>
<span :class="verData.premium ? 'bg-yellow-100 text-yellow-700' : 'text-gray-500'"
class="text-xs" x-text="verData.premium ? 'Sí' : 'No'"></span>
</div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Anual</span><span class="text-xs" x-text="verData.anual ? 'Sí' : 'No'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Renovación</span><span class="text-xs" x-text="verData.renovacion || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Fecha Premium</span><span class="text-xs" x-text="verData.premium_date ? verData.premium_date.substring(0,10) : '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Referido por</span><span class="text-xs" x-text="verData.reference_by || '—'"></span></div>
<div class="sm:col-span-2"><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">ID</span><span class="text-xs font-mono text-gray-400" x-text="verData._id || verData.id || '—'"></span></div>
</div>
</template>
<!-- Tab Perfil -->
<template x-if="!verLoading && verTab === 'perfil' && verData">
<div>
<template x-if="verData.perfil">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Email notificación</span><span x-text="verData.perfil.email_notificacion || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Ciudad</span><span x-text="verData.perfil.ciudad || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Indicativo</span><span x-text="verData.perfil.indicativo || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">País ID</span><span class="text-xs font-mono" x-text="verData.perfil.pais_id || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Empresa</span><span x-text="verData.perfil.nombre_empresa || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Sitio web</span>
<a :href="verData.perfil.sitio_web" target="_blank" x-show="verData.perfil.sitio_web"
class="text-blue-600 hover:underline text-xs" x-text="verData.perfil.sitio_web"></a>
<span x-show="!verData.perfil.sitio_web" class="text-gray-400"></span>
</div>
<div x-show="verData.perfil.logo" class="sm:col-span-2">
<span class="text-xs text-gray-400 uppercase tracking-wide block mb-1">Logo</span>
<img :src="verData.perfil.logo" class="h-16 rounded border" alt="Logo" onerror="this.style.display='none'"/>
</div>
</div>
</template>
<template x-if="!verData.perfil">
<p class="text-sm text-gray-400">Sin datos de perfil.</p>
</template>
</div>
</template>
<!-- Tab VCards -->
<template x-if="!verLoading && verTab === 'vcards'">
<div>
<div x-show="!verData || !verData.vcards || verData.vcards.length === 0" class="text-sm text-gray-400">Sin VCards.</div>
<div x-show="verData && verData.vcards && verData.vcards.length > 0" class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b">
<tr>
<th class="py-1 px-2">Nombre</th>
<th class="py-1 px-2">Empresa</th>
<th class="py-1 px-2">Email</th>
<th class="py-1 px-2">URL</th>
<th class="py-1 px-2">Estado</th>
<th class="py-1 px-2">Privacidad</th>
</tr>
</thead>
<tbody>
<template x-for="v in (verData && verData.vcards ? verData.vcards : [])" :key="v.id || v._id">
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="py-1 px-2 font-medium" x-text="v.nombre || '—'"></td>
<td class="py-1 px-2 text-gray-500" x-text="v.empresa || '—'"></td>
<td class="py-1 px-2 text-gray-500" x-text="v.email || '—'"></td>
<td class="py-1 px-2 text-blue-500">
<a :href="'https://vcard.u-site.app/'+v.url" target="_blank" x-text="v.url || '—'" class="hover:underline"></a>
</td>
<td class="py-1 px-2">
<span :class="v.estado ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="px-1.5 py-0.5 rounded-full" x-text="v.estado ? 'Activa' : 'Inactiva'"></span>
</td>
<td class="py-1 px-2">
<span :class="v.privacidad === 'publica' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'"
class="px-1.5 py-0.5 rounded-full" x-text="v.privacidad || '—'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<!-- Tab Wallet -->
<template x-if="!verLoading && verTab === 'wallet'">
<div>
<template x-if="verData && verData.wallet">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Balance</span>
<span class="text-lg font-semibold" x-text="verData.wallet.balance || '0'"></span>
</div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Moneda</span><span x-text="verData.wallet.moneda || '—'"></span></div>
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Estado</span>
<span :class="verData.wallet.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="text-xs px-2 py-0.5 rounded-full" x-text="verData.wallet.activo ? 'Activa' : 'Inactiva'"></span>
</div>
</div>
</template>
<template x-if="!(verData && verData.wallet)">
<p class="text-sm text-gray-400">Sin wallet asociada.</p>
</template>
</div>
</template>
<!-- Tab Pagos -->
<template x-if="!verLoading && verTab === 'pagos'">
<div>
<div x-show="verSectionLoading" class="text-xs text-gray-400">Cargando pagos...</div>
<div x-show="verSectionItems.length === 0 && !verSectionLoading" class="text-sm text-gray-400">Sin pagos.</div>
<div x-show="verSectionItems.length > 0" class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b">
<tr><th class="py-1 px-2">Referencia</th><th class="py-1 px-2">Monto</th><th class="py-1 px-2">Estado</th><th class="py-1 px-2">Fecha</th></tr>
</thead>
<tbody>
<template x-for="r in verSectionItems" :key="r.id || r._id">
<tr class="border-b border-gray-100">
<td class="py-1 px-2 font-mono" x-text="r.referencia || r.reference || '—'"></td>
<td class="py-1 px-2 font-medium" x-text="(r.moneda||'') + ' ' + (r.monto || r.amount || '—')"></td>
<td class="py-1 px-2">
<span :class="{'bg-green-100 text-green-700': r.estado==='completado','bg-yellow-100 text-yellow-700': r.estado==='pendiente','bg-red-100 text-red-700': r.estado==='fallido'}"
class="px-1.5 py-0.5 rounded-full" x-text="r.estado || '—'"></span>
</td>
<td class="py-1 px-2 text-gray-400" x-text="r.created_at ? r.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<!-- Tab Logs -->
<template x-if="!verLoading && verTab === 'logs'">
<div>
<div x-show="verSectionLoading" class="text-xs text-gray-400">Cargando logs...</div>
<div x-show="verSectionItems.length === 0 && !verSectionLoading" class="text-sm text-gray-400">Sin logs.</div>
<div x-show="verSectionItems.length > 0" class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b">
<tr><th class="py-1 px-2">Tipo</th><th class="py-1 px-2">Descripción</th><th class="py-1 px-2">Fecha</th></tr>
</thead>
<tbody>
<template x-for="r in verSectionItems" :key="r.id || r._id">
<tr class="border-b border-gray-100">
<td class="py-1 px-2 font-mono text-gray-700" x-text="r.type || r.tipo || '—'"></td>
<td class="py-1 px-2 text-gray-500 max-w-xs truncate" x-text="r.description || r.descripcion || '—'"></td>
<td class="py-1 px-2 text-gray-400" x-text="r.created_at ? r.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<!-- Tab MiniWebs -->
<template x-if="!verLoading && verTab === 'miniwebs'">
<div>
<div x-show="verSectionLoading" class="text-xs text-gray-400">Cargando miniwebs...</div>
<div x-show="verSectionItems.length === 0 && !verSectionLoading" class="text-sm text-gray-400">Sin miniwebs.</div>
<div x-show="verSectionItems.length > 0" class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead class="text-gray-500 uppercase border-b">
<tr><th class="py-1 px-2">Título</th><th class="py-1 px-2">VCard</th><th class="py-1 px-2">Estado</th></tr>
</thead>
<tbody>
<template x-for="r in verSectionItems" :key="r.id || r._id">
<tr class="border-b border-gray-100">
<td class="py-1 px-2 font-medium" x-text="r.titulo || r.title || '—'"></td>
<td class="py-1 px-2 text-gray-500" x-text="(r.vcard && r.vcard.nombre) ? r.vcard.nombre : '—'"></td>
<td class="py-1 px-2">
<span :class="r.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="px-1.5 py-0.5 rounded-full" x-text="r.activo ? 'Activa' : 'Inactiva'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex justify-between items-center gap-3 px-5 py-4 border-t shrink-0">
<button @click="openEditUsuarioModal(verData); verModal = false"
class="px-4 py-2 text-sm text-white rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">✏ Editar</button>
<button @click="verModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cerrar</button>
</div>
</div>
</div>
<!-- ─── Modal: Editar Usuario ────────────────────────────────────────── -->
<div x-show="editUsuarioModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
@@ -614,9 +726,14 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rol ID <span class="text-gray-400 font-normal">(opcional)</span></label>
<input x-model="editUsuarioForm.rol_id" type="text" placeholder="ID del rol"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
<select x-model="editUsuarioForm.rol_id"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">— Sin cambiar —</option>
<template x-for="r in roles" :key="r.id">
<option :value="r.id" x-text="r.nombre"></option>
</template>
</select>
</div>
<div x-show="editUsuarioError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="editUsuarioError"></div>
<div x-show="editUsuarioSuccess" class="p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700">✓ Usuario actualizado correctamente.</div>
@@ -847,12 +964,20 @@ function vcardApiApp() {
editVcardError: '',
editVcardSuccess: false,
// Drill-down por usuario
drillDownUserId: null,
drillDownSection: '',
drillDownItems: [],
drillDownLoading: false,
drillDownError: '',
// Modal ver usuario (detalle completo)
verModal: false,
verData: null,
verTab: 'general',
verLoading: false,
verError: '',
verSectionItems: [],
verSectionLoading: false,
// Roles conocidos (seed + acumulados de usuarios)
roles: [
{ id: '63c48dd0f0117680cf0cdb79', nombre: 'administrador' },
{ id: '63c48ddbf0117680cf0cdb7a', nombre: 'cliente' },
],
get totalPages() {
return Math.max(1, Math.ceil(this.total / this.perPage));
@@ -869,6 +994,7 @@ function vcardApiApp() {
this.membresiaModal = false;
this.editUsuarioModal = false;
this.editVcardModal = false;
this.verModal = false;
},
async loadConfig() {
@@ -937,7 +1063,6 @@ function vcardApiApp() {
this.items = [];
this.total = 0;
this.errorMsg = '';
this.closeDrillDown();
if (this.hasConfig) this.loadTab();
},
@@ -977,9 +1102,11 @@ function vcardApiApp() {
if (d.data && Array.isArray(d.data)) {
this.items = d.data;
this.total = d.total || d.data.length;
if (this.tab === 'usuarios') this._collectRoles(d.data);
} else if (Array.isArray(d)) {
this.items = d;
this.total = d.length;
if (this.tab === 'usuarios') this._collectRoles(d);
} else {
this.items = [];
this.total = 0;
@@ -989,6 +1116,17 @@ function vcardApiApp() {
this.loading = false;
},
_collectRoles(users) {
users.forEach(u => {
if (u.rol_id && u.rol && u.rol.nombre) {
const id = u.rol_id;
if (!this.roles.find(r => r.id === id)) {
this.roles.push({ id, nombre: u.rol.nombre });
}
}
});
},
clearFilters(tab) {
if (tab === 'pagos') this.filters.pagos = { estado: '', moneda: '', desde: '', hasta: '' };
if (tab === 'transacciones') this.filters.transacciones = { type: '', status: '' };
@@ -1119,47 +1257,44 @@ function vcardApiApp() {
this.saving = false;
},
// ─── Drill-down por usuario ──────────────────────────────────────────
// ─── Ver Usuario (detalle completo) ─────────────────────────────────
openDrillDown(u) {
async openVerModal(u) {
this.verData = u;
this.verTab = 'general';
this.verError = '';
this.verSectionItems = [];
this.verModal = true;
this.verLoading = true;
const id = u.id || u._id;
if (this.drillDownUserId === id) { this.closeDrillDown(); return; }
this.drillDownUserId = id;
this.drillDownSection = 'vcards';
this.drillDownItems = [];
this.drillDownError = '';
this.loadDrillDown('vcards');
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}`);
const d = await r.json();
if (!r.ok) { this.verError = d.error || `Error ${r.status}`; }
else { this.verData = d; }
} catch(e) { this.verError = e.message; }
this.verLoading = false;
},
closeDrillDown() {
this.drillDownUserId = null;
this.drillDownSection = '';
this.drillDownItems = [];
this.drillDownError = '';
},
async loadDrillDown(section) {
this.drillDownSection = section;
this.drillDownItems = [];
this.drillDownError = '';
if (!this.drillDownUserId) return;
this.drillDownLoading = true;
async loadVerSection(section) {
if (!['pagos','logs','miniwebs'].includes(section)) return;
this.verSectionItems = [];
this.verSectionLoading = true;
const id = this.verData && (this.verData.id || this.verData._id);
if (!id) { this.verSectionLoading = false; return; }
const map = {
vcards: `/app/vcard-api/usuarios/${this.drillDownUserId}/vcards`,
pagos: `/app/vcard-api/usuarios/${this.drillDownUserId}/pagos`,
transacciones: `/app/vcard-api/usuarios/${this.drillDownUserId}/transacciones`,
logs: `/app/vcard-api/usuarios/${this.drillDownUserId}/logs`,
miniwebs: `/app/vcard-api/usuarios/${this.drillDownUserId}/miniwebs`,
pagos: `/app/vcard-api/usuarios/${id}/pagos`,
logs: `/app/vcard-api/usuarios/${id}/logs`,
miniwebs: `/app/vcard-api/usuarios/${id}/miniwebs`,
};
try {
const r = await fetch(map[section]);
const d = await r.json();
if (!r.ok) { this.drillDownError = d.error || `Error ${r.status}`; }
else if (d.data && Array.isArray(d.data)) { this.drillDownItems = d.data; }
else if (Array.isArray(d)) { this.drillDownItems = d; }
else { this.drillDownError = 'Respuesta inesperada.'; }
} catch(e) { this.drillDownError = e.message; }
this.drillDownLoading = false;
if (!r.ok) {}
else if (d.data && Array.isArray(d.data)) { this.verSectionItems = d.data; }
else if (Array.isArray(d)) { this.verSectionItems = d; }
} catch(e) {}
this.verSectionLoading = false;
},
// ─── Membresía ───────────────────────────────────────────────────────