Files
soft_usite/resources/views/vcard_api.html
T
Lizandro GuarnizoandCopilot b43c144a82 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>
2026-05-20 09:34:43 -05:00

1375 lines
87 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Vista: VCard API — Integración Admin Laravel -->
<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">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">VCard API</h1>
<p class="text-xs text-slate-500 mt-0.5">Integración con el sistema VCard externo (Laravel + Sanctum). Gestiona usuarios, vcards, membresías y más.</p>
</div>
<button @click="configModal = true"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
Configurar API
</button>
</div>
<!-- Alerta sin configuración -->
<div x-show="!hasConfig" class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700">
<strong>Sin configuración activa.</strong> Haz clic en "Configurar API" para ingresar la URL base y el Bearer Token del sistema VCard.
</div>
<!-- Badge config activa -->
<div x-show="hasConfig && configData" class="mb-5 flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg text-sm">
<span class="h-2 w-2 rounded-full bg-green-500 inline-block"></span>
<span class="text-green-800 font-medium" x-text="configData ? configData.nombre || configData.base_url : ''"></span>
<span class="text-green-600 text-xs" x-text="configData ? configData.base_url : ''"></span>
<span x-show="configData && !configData.activo" class="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600">Inactiva</span>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex flex-wrap gap-4 text-sm">
<button @click="setTab('usuarios')" :class="tab==='usuarios' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Usuarios</button>
<button @click="setTab('vcards')" :class="tab==='vcards' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">VCards</button>
<button @click="setTab('planes')" :class="tab==='planes' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Planes</button>
<button @click="setTab('pagos')" :class="tab==='pagos' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Pagos</button>
<button @click="setTab('transacciones')" :class="tab==='transacciones' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Transacciones</button>
<button @click="setTab('logs')" :class="tab==='logs' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Logs</button>
<button @click="setTab('miniwebs')" :class="tab==='miniwebs' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">MiniWebs</button>
</nav>
</div>
<!-- Barra de búsqueda + cargar -->
<div class="flex flex-col sm:flex-row gap-3 mb-4">
<input x-model="search" @keyup.enter="loadTab()" type="text" placeholder="Buscar..." class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<button @click="loadTab()" :disabled="loading || !hasConfig"
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm hover:bg-gray-50 transition disabled:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="loading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>
<span x-text="loading ? 'Cargando...' : 'Cargar'"></span>
</button>
</div>
<!-- Alerta de error -->
<div x-show="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<!-- ─── TAB: Usuarios ─── -->
<div x-show="tab === 'usuarios'">
<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">
<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">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>
</tr>
</thead>
<tbody>
<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 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 === 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 === 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 === 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)"
class="text-blue-500 hover:text-blue-700 text-xs px-2 py-1 border border-blue-200 rounded">Membresía</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ─── TAB: VCards ─── -->
<div x-show="tab === 'vcards'">
<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">
<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">
<span :class="v.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="v.estado ? 'Activa' : 'Inactiva'"></span>
</td>
<td class="py-2 px-3">
<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>
</table>
</div>
</div>
<!-- ─── TAB: Planes ─── -->
<div x-show="tab === 'planes'">
<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="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="items.length > 0">
<template x-for="p in items" :key="p.id || p._id">
<div class="border rounded-xl p-4 bg-white shadow-sm hover:shadow-md transition">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold text-sm" x-text="p.nombre || '—'"></span>
<span :class="p.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="p.activo ? 'Activo' : 'Inactivo'"></span>
</div>
<p class="text-xs text-gray-500 mb-2" x-text="p.descripcion || ''"></p>
<div class="text-xs text-gray-600 space-y-1">
<div x-show="p.tarifas && p.tarifas.length">
<template x-for="t in (p.tarifas || [])" :key="t.id">
<div class="flex justify-between">
<span x-text="t.periodo || '—'"></span>
<span class="font-mono" x-text="(t.moneda || '') + ' ' + (t.precio || '—')"></span>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- ─── 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">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Referencia</th>
<th class="py-2 px-3">Usuario</th>
<th class="py-2 px-3">Monto</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3">Fecha</th>
</tr>
</thead>
<tbody>
<template x-for="p in items" :key="p.id || p._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-mono text-xs" x-text="p.referencia || p.reference || '—'"></td>
<td class="py-2 px-3 text-gray-500" x-text="p.user_id || '—'"></td>
<td class="py-2 px-3 font-medium" x-text="(p.moneda || '') + ' ' + (p.monto || p.amount || '—')"></td>
<td class="py-2 px-3">
<span :class="{
'bg-green-100 text-green-700': p.estado === 'completado',
'bg-yellow-100 text-yellow-700': p.estado === 'pendiente',
'bg-red-100 text-red-700': p.estado === 'fallido'
}" class="text-xs px-2 py-0.5 rounded-full" x-text="p.estado || '—'"></span>
</td>
<td class="py-2 px-3 text-xs text-gray-400" x-text="p.created_at ? p.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ─── 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">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Tipo</th>
<th class="py-2 px-3">Monto</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3">Descripción</th>
<th class="py-2 px-3">Fecha</th>
</tr>
</thead>
<tbody>
<template x-for="t in items" :key="t.id || t._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3">
<span class="bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded font-mono" x-text="t.type || '—'"></span>
</td>
<td class="py-2 px-3 font-medium" x-text="t.amount || '—'"></td>
<td class="py-2 px-3">
<span :class="{
'bg-green-100 text-green-700': t.status === 'completed',
'bg-yellow-100 text-yellow-700': t.status === 'pending',
'bg-red-100 text-red-700': t.status === 'failed'
}" class="text-xs px-2 py-0.5 rounded-full" x-text="t.status || '—'"></span>
</td>
<td class="py-2 px-3 text-xs text-gray-500 max-w-xs truncate" x-text="t.description || '—'"></td>
<td class="py-2 px-3 text-xs text-gray-400" x-text="t.created_at ? t.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ─── 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">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Tipo</th>
<th class="py-2 px-3">Usuario</th>
<th class="py-2 px-3">Descripción</th>
<th class="py-2 px-3">Fecha</th>
</tr>
</thead>
<tbody>
<template x-for="l in items" :key="l.id || l._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3">
<span class="bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded font-mono" x-text="l.type || l.tipo || '—'"></span>
</td>
<td class="py-2 px-3 text-gray-500 text-xs" x-text="(l.user && l.user.email) ? l.user.email : (l.user_id || '—')"></td>
<td class="py-2 px-3 text-xs text-gray-500 max-w-xs truncate" x-text="l.description || l.descripcion || '—'"></td>
<td class="py-2 px-3 text-xs text-gray-400" x-text="l.created_at ? l.created_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ─── TAB: MiniWebs ─── -->
<div x-show="tab === 'miniwebs'">
<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">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Título</th>
<th class="py-2 px-3">Usuario</th>
<th class="py-2 px-3">VCard vinculada</th>
<th class="py-2 px-3">Estado</th>
</tr>
</thead>
<tbody>
<template x-for="m in items" :key="m.id || m._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-medium" x-text="m.titulo || m.title || '—'"></td>
<td class="py-2 px-3 text-xs text-gray-500" x-text="(m.user && m.user.email) ? m.user.email : (m.user_id || '—')"></td>
<td class="py-2 px-3 text-xs text-gray-500" x-text="(m.vcard && m.vcard.nombre) ? m.vcard.nombre : '—'"></td>
<td class="py-2 px-3">
<span :class="m.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full" x-text="m.activo ? 'Activa' : 'Inactiva'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Paginación -->
<div x-show="total > 0" class="flex justify-between items-center mt-5 text-sm text-gray-500">
<span>Total: <b x-text="total"></b></span>
<div class="flex gap-1">
<button @click="changePage(page - 1)" :disabled="page <= 1" class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50"></button>
<span class="px-3 py-1" x-text="'Pág. ' + page + ' / ' + totalPages"></span>
<button @click="changePage(page + 1)" :disabled="page >= totalPages" class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50"></button>
</div>
</div>
</div>
<!-- ─── Modal: Configurar API ─────────────────────────────────────────── -->
<div x-show="configModal" 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">
<h2 class="text-lg font-semibold">Configurar VCard API</h2>
<button @click="configModal = 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 / Etiqueta</label>
<input x-model="form.nombre" type="text" placeholder="Ej: VCard Producción"
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">URL Base <span class="text-red-500">*</span></label>
<input x-model="form.base_url" type="url" placeholder="https://midominio.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p class="text-xs text-gray-400 mt-1">Sin barra final. Ej: https://midominio.com</p>
</div>
<!-- Login para obtener token automáticamente -->
<div class="border border-gray-200 rounded-lg p-4 space-y-3 bg-gray-50">
<p class="text-xs font-medium text-gray-600 uppercase tracking-wide">Obtener token automáticamente</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-600 mb-1">Email admin</label>
<input x-model="loginForm.email" type="email" placeholder="admin@dominio.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] bg-white" />
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">Contraseña</label>
<input x-model="loginForm.password" type="password" placeholder="••••••••"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] bg-white" />
</div>
</div>
<button @click="obtenerToken()" :disabled="loginLoading"
class="w-full px-4 py-2 text-sm border border-[#8eb02f] text-[#8eb02f] rounded-lg hover:bg-[#8eb02f] hover:text-white transition-colors disabled:opacity-50">
<span x-text="loginLoading ? 'Conectando...' : '⚡ Obtener Token'"></span>
</button>
<div x-show="loginMsg" class="text-xs rounded px-3 py-2"
:class="loginOk ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-600 border border-red-200'"
x-text="loginMsg"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer Token <span class="text-red-500">*</span></label>
<input x-model="form.bearer_token" type="password" placeholder="1|abc123..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p class="text-xs text-gray-400 mt-1">Se rellena automáticamente al obtener el token, o pégalo manualmente.</p>
</div>
<div class="flex items-center gap-2">
<input x-model="form.activo" type="checkbox" id="vcardActivo" class="rounded" />
<label for="vcardActivo" class="text-sm text-gray-700">Integración activa</label>
</div>
<div x-show="configError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="configError"></div>
</div>
<div class="flex justify-end gap-3 px-5 pb-5">
<button @click="configModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="saveConfig()" :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: 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">
<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</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>
</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">
<div class="flex justify-between items-center p-5 border-b">
<div>
<h2 class="text-lg font-semibold">Gestionar Membresía</h2>
<p class="text-xs text-gray-500 mt-0.5" x-text="membresiaUser ? (membresiaUser.name || membresiaUser.email) : ''"></p>
</div>
<button @click="membresiaModal = false" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="p-5 space-y-4">
<!-- Info membresía actual -->
<div x-show="membresiaInfo" class="p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs space-y-1">
<div class="flex justify-between"><span class="font-medium">Plan:</span><span x-text="membresiaInfo && membresiaInfo.plan ? membresiaInfo.plan.nombre : '—'"></span></div>
<div class="flex justify-between"><span class="font-medium">Vence:</span><span x-text="membresiaInfo && membresiaInfo.fecha_fin ? membresiaInfo.fecha_fin.substring(0,10) : '—'"></span></div>
<div class="flex justify-between"><span class="font-medium">Estado:</span>
<span :class="membresiaInfo && membresiaInfo.activo ? 'text-green-600' : 'text-red-500'"
x-text="membresiaInfo && membresiaInfo.activo ? 'Activa' : 'Inactiva'"></span>
</div>
</div>
<!-- Activar membresía -->
<div class="border rounded-lg p-3 space-y-2">
<p class="text-sm font-medium">Activar / Renovar</p>
<div class="flex items-center gap-2">
<input x-model="membresiaForm.anual" type="checkbox" id="mAnual" class="rounded" />
<label for="mAnual" class="text-sm text-gray-700">Anual (+365 días)</label>
</div>
<div x-show="!membresiaForm.anual">
<input x-model.number="membresiaForm.dias" type="number" min="1" placeholder="Días a agregar"
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>
<button @click="activarMembresia()" :disabled="saving"
class="w-full px-4 py-2 text-sm text-white rounded-lg disabled:opacity-50"
style="background-color:#8eb02f">
<span x-text="saving ? 'Procesando...' : 'Activar / Renovar'"></span>
</button>
</div>
<!-- Cambiar plan -->
<div class="border rounded-lg p-3 space-y-2">
<p class="text-sm font-medium">Cambiar Plan</p>
<select x-model="membresiaForm.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="">— Seleccionar plan —</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>
<button @click="cambiarPlan()" :disabled="saving || !membresiaForm.plan_id"
class="w-full px-4 py-2 text-sm border border-blue-300 text-blue-600 rounded-lg hover:bg-blue-50 disabled:opacity-50">
<span x-text="saving ? 'Procesando...' : 'Cambiar Plan'"></span>
</button>
</div>
<!-- Desactivar -->
<div x-show="membresiaInfo && membresiaInfo.activo">
<button @click="desactivarMembresia()" :disabled="saving"
class="w-full px-4 py-2 text-sm border border-red-200 text-red-500 rounded-lg hover:bg-red-50 disabled:opacity-50">
Desactivar membresía
</button>
</div>
<div x-show="membresiaError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="membresiaError"></div>
</div>
</div>
</div>
</div>
<script>
function vcardApiApp() {
return {
tab: 'usuarios',
items: [],
total: 0,
page: 1,
perPage: 20,
search: '',
loading: false,
saving: false,
errorMsg: '',
hasConfig: false,
configData: null,
configModal: false,
configError: '',
form: { nombre: '', base_url: '', bearer_token: '', activo: true },
loginForm: { email: '', password: '' },
loginLoading: false,
loginMsg: '',
loginOk: false,
membresiaModal: false,
membresiaUser: null,
membresiaInfo: null,
membresiaForm: { anual: true, dias: 30, plan_id: '' },
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,
// 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));
},
async init() {
await this.loadConfig();
if (this.hasConfig) this.loadTab();
this.loadPlanes();
},
closeModals() {
this.configModal = false;
this.membresiaModal = false;
this.editUsuarioModal = false;
this.editVcardModal = false;
this.verModal = false;
},
async loadConfig() {
try {
const r = await fetch('/app/vcard-api/config');
const d = await r.json();
if (d.config) {
this.configData = d.config;
this.hasConfig = !!d.config.activo;
this.form.nombre = d.config.nombre || '';
this.form.base_url = d.config.base_url || '';
this.form.activo = d.config.activo ?? true;
}
} catch(e) {}
},
async obtenerToken() {
this.loginMsg = '';
if (!this.form.base_url.trim()) { this.loginMsg = 'Ingresa primero la URL Base.'; this.loginOk = false; return; }
if (!this.loginForm.email || !this.loginForm.password) { this.loginMsg = 'Email y contraseña son requeridos.'; this.loginOk = false; return; }
this.loginLoading = true;
try {
const r = await fetch('/app/vcard-api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base_url: this.form.base_url, email: this.loginForm.email, password: this.loginForm.password })
});
const d = await r.json();
if (d.ok && d.token) {
this.form.bearer_token = d.token;
this.loginMsg = '✓ Token obtenido. Haz clic en Guardar para aplicar.';
this.loginOk = true;
} else {
this.loginMsg = d.error || 'Error al obtener el token.';
this.loginOk = false;
}
} catch(e) { this.loginMsg = e.message; this.loginOk = false; }
this.loginLoading = false;
},
async saveConfig() {
this.configError = '';
if (!this.form.base_url.trim()) { this.configError = 'La URL base es requerida.'; return; }
this.saving = true;
try {
const r = await fetch('/app/vcard-api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
const d = await r.json();
if (d.ok) {
this.configModal = false;
await this.loadConfig();
if (this.hasConfig) this.loadTab();
} else {
this.configError = d.error || 'Error al guardar.';
}
} catch(e) { this.configError = e.message; }
this.saving = false;
},
setTab(t) {
this.tab = t;
this.page = 1;
this.items = [];
this.total = 0;
this.errorMsg = '';
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 = '';
try {
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; }
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;
this.errorMsg = 'Respuesta inesperada de la API.';
}
} catch(e) { this.errorMsg = e.message; }
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: '' };
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');
const d = await r.json();
if (Array.isArray(d)) this.planes = d;
else if (d.data) this.planes = d.data;
} catch(e) {}
},
changePage(p) {
if (p < 1 || p > this.totalPages) return;
this.page = p;
this.loadTab();
},
async toggleUsuario(u, accion) {
if (!confirm(`¿${accion === 'activar' ? 'Activar' : 'Desactivar'} este usuario?`)) return;
this.loading = true;
const id = u.id || u._id;
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}/${accion}`, { method: 'POST' });
const d = await r.json();
if (!r.ok) { alert(d.error || 'Error al procesar.'); }
else await this.loadTab();
} catch(e) { alert(e.message); }
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;
},
// ─── Ver Usuario (detalle completo) ─────────────────────────────────
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;
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;
},
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 = {
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) {}
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 ───────────────────────────────────────────────────────
async openMembresiaModal(u) {
this.membresiaUser = u;
this.membresiaInfo = null;
this.membresiaError = '';
this.membresiaForm = { anual: true, dias: 30, plan_id: '' };
this.membresiaModal = true;
try {
const id = u.id || u._id;
const r = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
const d = await r.json();
this.membresiaInfo = d;
} catch(e) {}
},
async activarMembresia() {
this.membresiaError = '';
this.saving = true;
const id = this.membresiaUser.id || this.membresiaUser._id;
const body = { anual: this.membresiaForm.anual };
if (!this.membresiaForm.anual) body.dias = this.membresiaForm.dias;
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}/activar-membresia`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const d = await r.json();
if (!r.ok) this.membresiaError = d.error || d.message || 'Error al activar.';
else {
const rInfo = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
this.membresiaInfo = await rInfo.json();
}
} catch(e) { this.membresiaError = e.message; }
this.saving = false;
},
async desactivarMembresia() {
if (!confirm('¿Desactivar membresía?')) return;
this.membresiaError = '';
this.saving = true;
const id = this.membresiaUser.id || this.membresiaUser._id;
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}/desactivar-membresia`, { method: 'POST' });
const d = await r.json();
if (!r.ok) this.membresiaError = d.error || 'Error al desactivar.';
else this.membresiaInfo = { ...this.membresiaInfo, activo: false };
} catch(e) { this.membresiaError = e.message; }
this.saving = false;
},
async cambiarPlan() {
if (!this.membresiaForm.plan_id) return;
this.membresiaError = '';
this.saving = true;
const id = this.membresiaUser.id || this.membresiaUser._id;
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}/cambiar-plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_id: this.membresiaForm.plan_id, anual: this.membresiaForm.anual })
});
const d = await r.json();
if (!r.ok) this.membresiaError = d.error || d.message || 'Error al cambiar plan.';
else {
const rInfo = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
this.membresiaInfo = await rInfo.json();
}
} catch(e) { this.membresiaError = e.message; }
this.saving = false;
},
};
}
</script>