feat(vcard): agregar funcionalidad de edición de usuario y VCard, incluyendo filtros y modales

This commit is contained in:
Lizandro Guarnizo
2026-05-20 09:09:33 -05:00
parent 92a4272f53
commit 62e49b39ad
+562 -13
View File
@@ -1,5 +1,5 @@
<!-- Vista: VCard API — Integración Admin Laravel -->
<div x-data="vcardApiApp()" x-init="init()" class="bg-white rounded-lg shadow">
<div x-data="vcardApiApp()" x-init="init()" class="bg-white rounded-lg shadow" @keydown.escape.window="closeModals()">
<!-- Overlay de carga -->
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
@@ -93,16 +93,160 @@
class="text-xs px-2 py-0.5 rounded-full" x-text="u.estado ? 'Activo' : 'Inactivo'"></span>
</td>
<td class="py-2 px-3 text-right">
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2 flex-wrap">
<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"
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"
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"
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>
@@ -117,16 +261,19 @@
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Usuario</th>
<th class="py-2 px-3">Empresa</th>
<th class="py-2 px-3">Email</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3">Privacidad</th>
<th class="py-2 px-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<template x-for="v in items" :key="v.id || v._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-medium" x-text="v.nombre || '—'"></td>
<td class="py-2 px-3 text-xs text-gray-500" x-text="(v.user && v.user.email) ? v.user.email : (v.user_id || '—')"></td>
<td class="py-2 px-3 text-gray-500" x-text="v.empresa || '—'"></td>
<td class="py-2 px-3 text-gray-500" x-text="v.email || '—'"></td>
<td class="py-2 px-3">
@@ -137,6 +284,10 @@
<span :class="v.privacidad === 'publica' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'"
class="text-xs px-2 py-0.5 rounded-full" x-text="v.privacidad || '—'"></span>
</td>
<td class="py-2 px-3 text-right">
<button @click="openEditVcardModal(v)"
class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-300 rounded">Editar</button>
</td>
</tr>
</template>
</tbody>
@@ -173,6 +324,22 @@
<!-- ─── TAB: Pagos ─── -->
<div x-show="tab === 'pagos'">
<!-- Filtros pagos -->
<div class="flex flex-wrap gap-3 mb-4">
<select x-model="filters.pagos.estado" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">— Estado —</option>
<option value="pendiente">Pendiente</option>
<option value="completado">Completado</option>
<option value="fallido">Fallido</option>
</select>
<input x-model="filters.pagos.moneda" type="text" placeholder="Moneda (USD, COP...)"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-36 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<input x-model="filters.pagos.desde" type="date"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<input x-model="filters.pagos.hasta" type="date"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<button @click="clearFilters('pagos')" class="text-xs text-gray-400 hover:text-gray-600 px-2">✕ Limpiar</button>
</div>
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm" x-show="items.length > 0">
@@ -208,6 +375,25 @@
<!-- ─── TAB: Transacciones ─── -->
<div x-show="tab === 'transacciones'">
<!-- Filtros transacciones -->
<div class="flex flex-wrap gap-3 mb-4">
<select x-model="filters.transacciones.type" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">— Tipo —</option>
<option value="deposit">deposit</option>
<option value="withdraw">withdraw</option>
<option value="transfer_in">transfer_in</option>
<option value="transfer_out">transfer_out</option>
<option value="purchase">purchase</option>
<option value="adjustment">adjustment</option>
</select>
<select x-model="filters.transacciones.status" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">— Estado —</option>
<option value="pending">pending</option>
<option value="completed">completed</option>
<option value="failed">failed</option>
</select>
<button @click="clearFilters('transacciones')" class="text-xs text-gray-400 hover:text-gray-600 px-2">✕ Limpiar</button>
</div>
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm" x-show="items.length > 0">
@@ -245,6 +431,21 @@
<!-- ─── TAB: Logs ─── -->
<div x-show="tab === 'logs'">
<!-- Filtros logs -->
<div class="flex flex-wrap gap-3 mb-4">
<select x-model="filters.logs.type" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">— Tipo —</option>
<option value="create">create</option>
<option value="edit">edit</option>
<option value="delete">delete</option>
<option value="delete cuenta">delete cuenta</option>
</select>
<input x-model="filters.logs.desde" type="date"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<input x-model="filters.logs.hasta" type="date"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<button @click="clearFilters('logs')" class="text-xs text-gray-400 hover:text-gray-600 px-2">✕ Limpiar</button>
</div>
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm" x-show="items.length > 0">
@@ -381,6 +582,156 @@
</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">
<div class="flex justify-between items-center p-5 border-b">
<div>
<h2 class="text-lg font-semibold">Editar Usuario</h2>
<p class="text-xs text-gray-500 mt-0.5" x-text="editUsuarioData ? (editUsuarioData.email || '') : ''"></p>
</div>
<button @click="editUsuarioModal = false" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
<input x-model="editUsuarioForm.name" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input x-model="editUsuarioForm.email" type="email"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Plan ID</label>
<select x-model="editUsuarioForm.plan_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="pl in planes" :key="pl.id || pl._id">
<option :value="pl.id || pl._id" x-text="pl.nombre"></option>
</template>
</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]" />
</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>
</div>
<div class="flex justify-end gap-3 px-5 pb-5">
<button @click="editUsuarioModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="saveEditUsuario()" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : 'Guardar'"></span>
</button>
</div>
</div>
</div>
<!-- ─── Modal: Editar VCard ───────────────────────────────────────────── -->
<div x-show="editVcardModal" 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-lg mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center p-5 border-b sticky top-0 bg-white z-10">
<div>
<h2 class="text-lg font-semibold">Editar VCard</h2>
<p class="text-xs text-gray-500 mt-0.5" x-text="editVcardData ? (editVcardData.nombre || '') : ''"></p>
</div>
<button @click="editVcardModal = false" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre</label>
<input x-model="editVcardForm.nombre" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Cargo</label>
<input x-model="editVcardForm.cargo" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Descripción</label>
<textarea x-model="editVcardForm.descripcion" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Teléfono</label>
<input x-model="editVcardForm.telefono" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">WhatsApp</label>
<input x-model="editVcardForm.whatsapp" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input x-model="editVcardForm.email" type="email"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Empresa</label>
<input x-model="editVcardForm.empresa" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Dirección</label>
<input x-model="editVcardForm.direccion" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Website</label>
<input x-model="editVcardForm.website" type="url"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">País</label>
<input x-model="editVcardForm.pais" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Ciudad</label>
<input x-model="editVcardForm.ciudad" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Privacidad</label>
<select x-model="editVcardForm.privacidad"
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="publica">Pública</option>
<option value="privada">Privada</option>
</select>
</div>
<div class="flex items-center gap-4 sm:col-span-2">
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input x-model="editVcardForm.estado" type="checkbox" class="rounded" /> Activa
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input x-model="editVcardForm.es_borrador" type="checkbox" class="rounded" /> Borrador
</label>
</div>
<div x-show="editVcardError" class="sm:col-span-2 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="editVcardError"></div>
<div x-show="editVcardSuccess" class="sm:col-span-2 p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700">✓ VCard actualizada correctamente.</div>
</div>
<div class="flex justify-end gap-3 px-5 pb-5">
<button @click="editVcardModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="saveEditVcard()" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : 'Guardar'"></span>
</button>
</div>
</div>
</div>
<!-- ─── Modal: Membresía ──────────────────────────────────────────────── -->
<div x-show="membresiaModal" 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">
@@ -475,6 +826,34 @@ function vcardApiApp() {
membresiaError: '',
planes: [],
// Filtros por tab
filters: {
pagos: { estado: '', moneda: '', desde: '', hasta: '' },
transacciones: { type: '', status: '' },
logs: { type: '', desde: '', hasta: '' },
},
// Modal editar usuario
editUsuarioModal: false,
editUsuarioData: null,
editUsuarioForm: { name: '', email: '', rol_id: '', plan_id: '' },
editUsuarioError: '',
editUsuarioSuccess: false,
// Modal editar vcard
editVcardModal: false,
editVcardData: null,
editVcardForm: { nombre: '', cargo: '', descripcion: '', telefono: '', whatsapp: '', email: '', empresa: '', direccion: '', website: '', pais: '', ciudad: '', estado: true, privacidad: 'publica', es_borrador: false },
editVcardError: '',
editVcardSuccess: false,
// Drill-down por usuario
drillDownUserId: null,
drillDownSection: '',
drillDownItems: [],
drillDownLoading: false,
drillDownError: '',
get totalPages() {
return Math.max(1, Math.ceil(this.total / this.perPage));
},
@@ -485,6 +864,13 @@ function vcardApiApp() {
this.loadPlanes();
},
closeModals() {
this.configModal = false;
this.membresiaModal = false;
this.editUsuarioModal = false;
this.editVcardModal = false;
},
async loadConfig() {
try {
const r = await fetch('/app/vcard-api/config');
@@ -551,27 +937,43 @@ function vcardApiApp() {
this.items = [];
this.total = 0;
this.errorMsg = '';
this.closeDrillDown();
if (this.hasConfig) this.loadTab();
},
buildEndpoint() {
const qs = (p) => Object.entries(p).filter(([,v]) => v !== '').map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&');
const base = {
usuarios: `/app/vcard-api/usuarios?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
vcards: `/app/vcard-api/vcards?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
planes: `/app/vcard-api/planes`,
pagos: `/app/vcard-api/pagos?per_page=${this.perPage}&page=${this.page}`,
transacciones: `/app/vcard-api/transacciones?per_page=${this.perPage}&page=${this.page}`,
logs: `/app/vcard-api/logs?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
miniwebs: `/app/vcard-api/miniwebs?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
};
let url = base[this.tab] || '';
if (this.tab === 'pagos') {
const f = qs(this.filters.pagos);
if (f) url += '&' + f;
} else if (this.tab === 'transacciones') {
const f = qs(this.filters.transacciones);
if (f) url += '&' + f;
} else if (this.tab === 'logs') {
const f = qs(this.filters.logs);
if (f) url += '&' + f;
}
return url;
},
async loadTab() {
if (!this.hasConfig) return;
this.loading = true;
this.errorMsg = '';
const endpoints = {
usuarios: `/app/vcard-api/usuarios?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
vcards: `/app/vcard-api/vcards?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
planes: `/app/vcard-api/planes`,
pagos: `/app/vcard-api/pagos?per_page=${this.perPage}&page=${this.page}`,
transacciones: `/app/vcard-api/transacciones?per_page=${this.perPage}&page=${this.page}`,
logs: `/app/vcard-api/logs?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
miniwebs: `/app/vcard-api/miniwebs?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
};
try {
const r = await fetch(endpoints[this.tab]);
const r = await fetch(this.buildEndpoint());
const d = await r.json();
if (!r.ok) { this.errorMsg = d.error || `Error ${r.status}`; this.loading = false; return; }
// Laravel pagination o array plano
if (d.data && Array.isArray(d.data)) {
this.items = d.data;
this.total = d.total || d.data.length;
@@ -587,6 +989,14 @@ function vcardApiApp() {
this.loading = false;
},
clearFilters(tab) {
if (tab === 'pagos') this.filters.pagos = { estado: '', moneda: '', desde: '', hasta: '' };
if (tab === 'transacciones') this.filters.transacciones = { type: '', status: '' };
if (tab === 'logs') this.filters.logs = { type: '', desde: '', hasta: '' };
this.page = 1;
this.loadTab();
},
async loadPlanes() {
try {
const r = await fetch('/app/vcard-api/planes');
@@ -615,6 +1025,145 @@ function vcardApiApp() {
this.loading = false;
},
// ─── Edit Usuario ────────────────────────────────────────────────────
openEditUsuarioModal(u) {
this.editUsuarioData = u;
this.editUsuarioForm = {
name: u.name || u.nombre || '',
email: u.email || '',
rol_id: (u.rol && (u.rol.id || u.rol._id)) ? (u.rol.id || u.rol._id) : '',
plan_id: (u.plan && (u.plan.id || u.plan._id)) ? (u.plan.id || u.plan._id) : '',
};
this.editUsuarioError = '';
this.editUsuarioSuccess = false;
this.editUsuarioModal = true;
},
async saveEditUsuario() {
this.editUsuarioError = '';
this.editUsuarioSuccess = false;
if (!this.editUsuarioForm.name.trim() || !this.editUsuarioForm.email.trim()) {
this.editUsuarioError = 'Nombre y email son requeridos.'; return;
}
this.saving = true;
const id = this.editUsuarioData.id || this.editUsuarioData._id;
const body = {};
if (this.editUsuarioForm.name) body.name = this.editUsuarioForm.name;
if (this.editUsuarioForm.email) body.email = this.editUsuarioForm.email;
if (this.editUsuarioForm.rol_id) body.rol_id = this.editUsuarioForm.rol_id;
if (this.editUsuarioForm.plan_id) body.plan_id = this.editUsuarioForm.plan_id;
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const d = await r.json();
if (!r.ok) {
this.editUsuarioError = d.error || d.message || `Error ${r.status}`;
} else {
this.editUsuarioSuccess = true;
await this.loadTab();
setTimeout(() => { this.editUsuarioModal = false; }, 1200);
}
} catch(e) { this.editUsuarioError = e.message; }
this.saving = false;
},
// ─── Edit VCard ──────────────────────────────────────────────────────
openEditVcardModal(v) {
this.editVcardData = v;
this.editVcardForm = {
nombre: v.nombre || '',
cargo: v.cargo || '',
descripcion: v.descripcion || '',
telefono: v.telefono || '',
whatsapp: v.whatsapp || '',
email: v.email || '',
empresa: v.empresa || '',
direccion: v.direccion || '',
website: v.website || '',
pais: v.pais || '',
ciudad: v.ciudad || '',
estado: !!v.estado,
privacidad: v.privacidad || 'publica',
es_borrador: !!v.es_borrador,
};
this.editVcardError = '';
this.editVcardSuccess = false;
this.editVcardModal = true;
},
async saveEditVcard() {
this.editVcardError = '';
this.editVcardSuccess = false;
this.saving = true;
const id = this.editVcardData.id || this.editVcardData._id;
try {
const r = await fetch(`/app/vcard-api/vcards/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.editVcardForm)
});
const d = await r.json();
if (!r.ok) {
this.editVcardError = d.error || d.message || `Error ${r.status}`;
} else {
this.editVcardSuccess = true;
await this.loadTab();
setTimeout(() => { this.editVcardModal = false; }, 1200);
}
} catch(e) { this.editVcardError = e.message; }
this.saving = false;
},
// ─── Drill-down por usuario ──────────────────────────────────────────
openDrillDown(u) {
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');
},
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;
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`,
};
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;
},
// ─── Membresía ───────────────────────────────────────────────────────
async openMembresiaModal(u) {
this.membresiaUser = u;
this.membresiaInfo = null;