2317 lines
150 KiB
HTML
2317 lines
150 KiB
HTML
<!-- 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>
|
||
|
||
<!-- Toolbar: filtro plan + ordenar por vencimiento -->
|
||
<div class="flex flex-wrap items-center gap-3 mb-3" x-show="items.length > 0">
|
||
<select x-model="filterPlan" class="border border-gray-300 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
||
<option value="">— Todos los planes —</option>
|
||
<template x-for="p in planesDisponibles()" :key="p">
|
||
<option :value="p" x-text="p"></option>
|
||
</template>
|
||
</select>
|
||
<button @click="toggleSortVenc()" :disabled="loadingVenc"
|
||
:class="sortByVenc ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'text-gray-600 border-gray-300 hover:bg-gray-50'"
|
||
class="flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition disabled:opacity-50">
|
||
<svg x-show="loadingVenc" class="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
<svg x-show="!loadingVenc" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||
</svg>
|
||
<span x-text="loadingVenc ? 'Cargando membresías...' : (sortByVenc ? '✓ Ordenado por vencimiento' : 'Ordenar por vencimiento')"></span>
|
||
</button>
|
||
<div class="flex items-center gap-2 text-xs" x-show="sortByVenc">
|
||
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-700">Vencido / ≤7 días</span>
|
||
<span class="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">8–30 días</span>
|
||
<span class="px-2 py-0.5 rounded-full bg-green-100 text-green-700">>30 días</span>
|
||
</div>
|
||
</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" x-show="sortByVenc">Vence</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 usuariosFiltrados()" :key="u.id || u._id">
|
||
<tr :class="sortByVenc ? vencimientoRowClass(u) : 'hover:bg-gray-50'" class="border-b border-gray-100 transition-colors">
|
||
<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 text-xs" x-show="sortByVenc">
|
||
<span :class="vencimientoBadgeClass(u)" class="px-2 py-0.5 rounded-full whitespace-nowrap" x-text="vencimientoTexto(u)"></span>
|
||
</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 x-show="u.estado === true || u.estado === 'true'" @click="openDesactivarConVcardsModal(u)"
|
||
class="text-orange-500 hover:text-orange-700 text-xs px-2 py-1 border border-orange-300 rounded" title="Inactiva todas las VCards (sin desactivar usuario)">
|
||
🚫 VCards
|
||
</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 / Cargo</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">URL</th>
|
||
<th class="py-2 px-3">Estado</th>
|
||
<th class="py-2 px-3">Borrador</th>
|
||
<th class="py-2 px-3">Privacidad</th>
|
||
<th class="py-2 px-3">País / Ciudad</th>
|
||
<th class="py-2 px-3">Creada</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">
|
||
<div class="font-medium" x-text="v.nombre || '—'"></div>
|
||
<div class="text-xs text-gray-400" x-text="v.cargo || ''"></div>
|
||
</td>
|
||
<td class="py-2 px-3 text-xs text-gray-500">
|
||
<div x-text="(v.user && v.user.name) ? v.user.name : '—'"></div>
|
||
<div class="text-gray-400" x-text="(v.user && v.user.email) ? v.user.email : (v.user_id || '')"></div>
|
||
</td>
|
||
<td class="py-2 px-3 text-gray-500 text-xs" x-text="v.empresa || '—'"></td>
|
||
<td class="py-2 px-3 text-gray-500 text-xs" x-text="v.email || '—'"></td>
|
||
<td class="py-2 px-3 text-xs">
|
||
<a :href="v.url || (v.unico ? 'https://vcard.u-site.app/' + v.unico : null)" target="_blank"
|
||
x-show="v.unico || v.url"
|
||
class="text-blue-500 hover:underline font-mono" x-text="v.unico || '—'"></a>
|
||
<span x-show="!v.unico && !v.url" class="text-gray-400">—</span>
|
||
</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 x-show="v.es_borrador" class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Borrador</span>
|
||
<span x-show="!v.es_borrador" class="text-xs text-gray-300">—</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-xs text-gray-500">
|
||
<div x-text="v.pais || '—'"></div>
|
||
<div class="text-gray-400" x-text="v.ciudad || ''"></div>
|
||
</td>
|
||
<td class="py-2 px-3 text-xs text-gray-400" x-text="v.created_at ? v.created_at.substring(0,10) : '—'"></td>
|
||
<td class="py-2 px-3 text-right">
|
||
<div class="flex justify-end gap-2">
|
||
<button @click="openVerVcardModal(v)"
|
||
class="text-indigo-500 hover:text-indigo-700 text-xs px-2 py-1 border border-indigo-200 rounded">Ver</button>
|
||
<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>
|
||
</div>
|
||
</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">
|
||
<!-- Header -->
|
||
<div class="flex items-start justify-between mb-2 gap-2">
|
||
<div class="flex items-center gap-2">
|
||
<img x-show="p.img" :src="p.img ? 'https://vcard.u-site.app/' + p.img : null"
|
||
class="h-8 w-8 rounded object-cover border shrink-0" onerror="this.style.display='none'" />
|
||
<span class="font-semibold text-sm" x-text="p.nombre || '—'"></span>
|
||
</div>
|
||
<span :class="p.estado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||
class="text-xs px-2 py-0.5 rounded-full shrink-0" x-text="p.estado ? 'Activo' : 'Inactivo'"></span>
|
||
</div>
|
||
<!-- Descripción -->
|
||
<p class="text-xs text-gray-500 mb-2 line-clamp-2" x-text="p.descripcion || ''"></p>
|
||
<!-- Cantidad vcards -->
|
||
<div x-show="p.cantidad" class="text-xs text-gray-600 mb-2">
|
||
<span class="font-medium">VCards permitidas:</span>
|
||
<span x-text="p.cantidad"></span>
|
||
</div>
|
||
<!-- Precios únicos (resumidos) -->
|
||
<div x-show="p.tarifas && p.tarifas.length" class="mt-2">
|
||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Precios (muestra)</p>
|
||
<div class="flex flex-wrap gap-1">
|
||
<template x-for="t in uniqueMontos(p.tarifas || [])" :key="t">
|
||
<span class="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded font-mono" x-text="t === '0' || t === 'Gratis' ? 'Gratis' : ('$' + t)"></span>
|
||
</template>
|
||
</div>
|
||
<p class="text-xs text-gray-400 mt-1" x-text="(p.tarifas||[]).length + ' tarifas por país'"></p>
|
||
</div>
|
||
<!-- ID -->
|
||
<p class="text-xs text-gray-300 font-mono mt-3 truncate" x-text="p._id || p.id"></p>
|
||
<!-- Acciones -->
|
||
<div class="flex gap-2 mt-3 pt-3 border-t">
|
||
<button @click="openVerPlanModal(p)"
|
||
class="flex-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600">
|
||
Ver
|
||
</button>
|
||
<button @click="openEditPlanModal(p)"
|
||
class="flex-1 px-3 py-1.5 text-xs text-white rounded-lg"
|
||
style="background-color:#8eb02f"
|
||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||
Editar
|
||
</button>
|
||
</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="v.url ? 'https://vcard.u-site.app/'+v.url : null" 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: Ver VCard ─────────────────────────────────────────────── -->
|
||
<div x-show="verVcardModal" 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="verVcardData ? (verVcardData.nombre || '—') : 'Cargando...'"></h2>
|
||
<p class="text-xs text-gray-500 mt-0.5" x-text="verVcardData ? (verVcardData.cargo || '') : ''"></p>
|
||
<div class="flex flex-wrap gap-2 mt-1">
|
||
<template x-if="verVcardData">
|
||
<span :class="verVcardData.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="verVcardData.estado ? 'Activa' : 'Inactiva'"></span>
|
||
</template>
|
||
<template x-if="verVcardData && verVcardData.es_borrador">
|
||
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Borrador</span>
|
||
</template>
|
||
<template x-if="verVcardData && verVcardData.privacidad">
|
||
<span :class="verVcardData.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="verVcardData.privacidad"></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<button @click="verVcardModal = false" class="text-gray-400 hover:text-gray-600 text-xl shrink-0 ml-3">✕</button>
|
||
</div>
|
||
<!-- Tabs -->
|
||
<div class="border-b px-5 flex gap-1 overflow-x-auto shrink-0">
|
||
<template x-for="vt in ['info','redes','apariencia']" :key="vt">
|
||
<button @click="verVcardTab = vt"
|
||
:class="verVcardTab === vt ? '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"
|
||
x-text="vt === 'info' ? 'Información' : vt.charAt(0).toUpperCase()+vt.slice(1)"></button>
|
||
</template>
|
||
</div>
|
||
<!-- Content -->
|
||
<div class="overflow-y-auto flex-1 p-5">
|
||
<!-- Tab Información -->
|
||
<template x-if="verVcardTab === 'info' && verVcardData">
|
||
<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="verVcardData.nombre || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Cargo</span><span x-text="verVcardData.cargo || '—'"></span></div>
|
||
<div class="sm:col-span-2"><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Descripción</span><p class="text-sm text-gray-600" x-text="verVcardData.descripcion || '—'"></p></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Empresa</span><span x-text="verVcardData.empresa || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Email</span><span x-text="verVcardData.email || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Teléfono</span>
|
||
<span x-text="(verVcardData.consecutivo_telefono||'') + ' ' + (verVcardData.telefono||'—')"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">WhatsApp</span>
|
||
<span x-text="(verVcardData.consecutivo_whatsapp||'') + ' ' + (verVcardData.whatsapp||'—')"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Dirección</span><span x-text="verVcardData.direccion || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Website</span>
|
||
<a :href="verVcardData.website" target="_blank" x-show="verVcardData.website"
|
||
class="text-blue-600 hover:underline text-xs" x-text="verVcardData.website"></a>
|
||
<span x-show="!verVcardData.website" class="text-gray-400">—</span>
|
||
</div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">País</span><span x-text="verVcardData.pais || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Ciudad</span><span x-text="verVcardData.ciudad || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Usuario</span>
|
||
<span x-text="(verVcardData.user && verVcardData.user.name) ? verVcardData.user.name : '—'"></span><br>
|
||
<span class="text-xs text-gray-400" x-text="(verVcardData.user && verVcardData.user.email) ? verVcardData.user.email : ''"></span>
|
||
</div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Creada</span>
|
||
<span class="text-xs" x-text="verVcardData.created_at ? verVcardData.created_at.substring(0,10) : '—'"></span></div>
|
||
<div x-show="verVcardData.foto_perfil">
|
||
<span class="text-xs text-gray-400 uppercase tracking-wide block mb-1">Foto perfil</span>
|
||
<img :src="verVcardData.foto_perfil" class="h-14 w-14 rounded-full object-cover border" onerror="this.style.display='none'" />
|
||
</div>
|
||
<div x-show="verVcardData.logo">
|
||
<span class="text-xs text-gray-400 uppercase tracking-wide block mb-1">Logo</span>
|
||
<img :src="verVcardData.logo" class="h-14 rounded border object-contain" onerror="this.style.display='none'" />
|
||
</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="verVcardData._id || verVcardData.id || '—'"></span></div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Tab Redes -->
|
||
<template x-if="verVcardTab === 'redes' && verVcardData">
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||
<template x-for="red in [
|
||
{key:'linkedin',label:'LinkedIn'},{key:'twitter',label:'Twitter / X'},
|
||
{key:'facebook',label:'Facebook'},{key:'instagram',label:'Instagram'},
|
||
{key:'tiktok',label:'TikTok'},{key:'github',label:'GitHub'},
|
||
{key:'behance',label:'Behance'},{key:'youtube',label:'YouTube'},
|
||
{key:'youtube_channel',label:'Canal YouTube'},{key:'telegram',label:'Telegram'},
|
||
{key:'messenger',label:'Messenger'},{key:'wechat',label:'WeChat'},
|
||
{key:'signal',label:'Signal'},{key:'line',label:'Line'}
|
||
]" :key="red.key">
|
||
<div x-show="verVcardData[red.key]">
|
||
<span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5" x-text="red.label"></span>
|
||
<span class="text-sm" x-text="verVcardData[red.key]"></span>
|
||
</div>
|
||
</template>
|
||
<div x-show="!['linkedin','twitter','facebook','instagram','tiktok','github','behance','youtube','youtube_channel','telegram','messenger','wechat','signal','line'].some(k => verVcardData[k])"
|
||
class="sm:col-span-2 text-sm text-gray-400">Sin redes sociales configuradas.</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Tab Apariencia -->
|
||
<template x-if="verVcardTab === 'apariencia' && verVcardData">
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||
<template x-for="c in [
|
||
{key:'cabecera',label:'Cabecera'},{key:'fondo',label:'Fondo'},
|
||
{key:'color_cuerpo',label:'Cuerpo'},{key:'color_iconos',label:'Iconos'},
|
||
{key:'color_info',label:'Info'}
|
||
]" :key="c.key">
|
||
<div x-show="verVcardData[c.key]">
|
||
<span class="text-xs text-gray-400 uppercase tracking-wide block mb-1" x-text="c.label"></span>
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-6 w-6 rounded border border-gray-200 shrink-0" :style="'background:' + verVcardData[c.key]"></div>
|
||
<span class="font-mono text-xs" x-text="verVcardData[c.key]"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Fuente</span><span x-text="verVcardData.fuente || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Bordes</span><span x-text="verVcardData.bordes || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Estilo</span><span x-text="verVcardData.estilo_vcard || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Orden iconos</span><span x-text="verVcardData.orden_iconos || '—'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Animación</span><span x-text="verVcardData.animacion || 'Sin animación'"></span></div>
|
||
<div><span class="text-xs text-gray-400 uppercase tracking-wide block mb-0.5">Botón</span><span x-text="verVcardData.boton || '—'"></span></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- Footer -->
|
||
<div class="flex justify-between items-center gap-3 px-5 py-4 border-t shrink-0">
|
||
<div class="flex gap-2">
|
||
<a :href="verVcardData ? (verVcardData.url || (verVcardData.unico ? 'https://vcard.u-site.app/' + verVcardData.unico : '#')) : '#'"
|
||
target="_blank"
|
||
class="inline-flex items-center gap-1 px-4 py-2 text-sm text-white rounded-lg"
|
||
style="background-color:#3b82f6"
|
||
onmouseover="this.style.backgroundColor='#2563eb'"
|
||
onmouseout="this.style.backgroundColor='#3b82f6'">
|
||
🔗 Ver VCard
|
||
</a>
|
||
<button @click="openEditVcardModal(verVcardData); verVcardModal = 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>
|
||
</div>
|
||
<button @click="verVcardModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cerrar</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-2xl mx-4 max-h-[90vh] flex flex-col">
|
||
<div class="flex justify-between items-center p-5 border-b shrink-0">
|
||
<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>
|
||
<!-- Tabs del modal -->
|
||
<div class="border-b px-5 flex gap-1 shrink-0 overflow-x-auto">
|
||
<template x-for="vt in ['datos','redes','apariencia']" :key="vt">
|
||
<button @click="editVcardTab = vt"
|
||
:class="editVcardTab === vt ? '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" x-text="vt.charAt(0).toUpperCase()+vt.slice(1)"></button>
|
||
</template>
|
||
</div>
|
||
<div class="overflow-y-auto flex-1 p-5">
|
||
<!-- Tab Datos -->
|
||
<div x-show="editVcardTab === 'datos'" class="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>
|
||
<div class="flex gap-1">
|
||
<input x-model="editVcardForm.consecutivo_telefono" type="text" placeholder="+57" class="w-16 border border-gray-300 rounded-lg px-2 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
<input x-model="editVcardForm.telefono" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">WhatsApp</label>
|
||
<div class="flex gap-1">
|
||
<input x-model="editVcardForm.consecutivo_whatsapp" type="text" placeholder="+57" class="w-16 border border-gray-300 rounded-lg px-2 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
<input x-model="editVcardForm.whatsapp" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</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">Foto perfil (URL)</label>
|
||
<input x-model="editVcardForm.foto_perfil" 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">Logo (URL)</label>
|
||
<input x-model="editVcardForm.logo" 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">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>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Texto botón</label>
|
||
<input x-model="editVcardForm.boton" 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="flex items-center gap-4 sm:col-span-2 pt-1">
|
||
<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>
|
||
|
||
<!-- Tab Redes Sociales -->
|
||
<div x-show="editVcardTab === 'redes'" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<template x-for="red in [
|
||
{key:'linkedin', label:'LinkedIn'},
|
||
{key:'twitter', label:'Twitter / X'},
|
||
{key:'facebook', label:'Facebook'},
|
||
{key:'instagram', label:'Instagram'},
|
||
{key:'tiktok', label:'TikTok'},
|
||
{key:'github', label:'GitHub'},
|
||
{key:'behance', label:'Behance'},
|
||
{key:'youtube', label:'YouTube'},
|
||
{key:'youtube_channel', label:'Canal YouTube'},
|
||
{key:'telegram', label:'Telegram'},
|
||
{key:'messenger', label:'Messenger'},
|
||
{key:'wechat', label:'WeChat'},
|
||
{key:'signal', label:'Signal'},
|
||
{key:'line', label:'Line'}
|
||
]" :key="red.key">
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1" x-text="red.label"></label>
|
||
<div class="flex gap-1" x-show="red.key === 'signal'">
|
||
<input x-model="editVcardForm.consecutivo_signal" type="text" placeholder="+57" class="w-16 border border-gray-300 rounded-lg px-2 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
<input x-model="editVcardForm[red.key]" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
<input x-show="red.key !== 'signal'" x-model="editVcardForm[red.key]" 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>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Tab Apariencia -->
|
||
<div x-show="editVcardTab === 'apariencia'" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Color cabecera</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input x-model="editVcardForm.cabecera" type="color" class="h-9 w-12 border border-gray-300 rounded cursor-pointer" />
|
||
<input x-model="editVcardForm.cabecera" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Color fondo</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input x-model="editVcardForm.fondo" type="color" class="h-9 w-12 border border-gray-300 rounded cursor-pointer" />
|
||
<input x-model="editVcardForm.fondo" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Color cuerpo</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input x-model="editVcardForm.color_cuerpo" type="color" class="h-9 w-12 border border-gray-300 rounded cursor-pointer" />
|
||
<input x-model="editVcardForm.color_cuerpo" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Color iconos</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input x-model="editVcardForm.color_iconos" type="color" class="h-9 w-12 border border-gray-300 rounded cursor-pointer" />
|
||
<input x-model="editVcardForm.color_iconos" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Color info</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input x-model="editVcardForm.color_info" type="color" class="h-9 w-12 border border-gray-300 rounded cursor-pointer" />
|
||
<input x-model="editVcardForm.color_info" type="text" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Fuente</label>
|
||
<select x-model="editVcardForm.fuente" 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="Inter">Inter</option>
|
||
<option value="Roboto">Roboto</option>
|
||
<option value="Montserrat">Montserrat</option>
|
||
<option value="Poppins">Poppins</option>
|
||
<option value="Lato">Lato</option>
|
||
<option value="Open Sans">Open Sans</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Bordes</label>
|
||
<select x-model="editVcardForm.bordes" 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="rounded-full">Circular</option>
|
||
<option value="rounded-lg">Redondeado</option>
|
||
<option value="rounded-none">Recto</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Estilo VCard</label>
|
||
<select x-model="editVcardForm.estilo_vcard" 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="1">Estilo 1</option>
|
||
<option :value="2">Estilo 2</option>
|
||
<option :value="3">Estilo 3</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Orden iconos</label>
|
||
<select x-model="editVcardForm.orden_iconos" 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="top">Arriba</option>
|
||
<option value="bottom">Abajo</option>
|
||
<option value="left">Izquierda</option>
|
||
<option value="right">Derecha</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Animación</label>
|
||
<select x-model="editVcardForm.animacion" 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 animación</option>
|
||
<option value="fade">Fade</option>
|
||
<option value="slide">Slide</option>
|
||
<option value="zoom">Zoom</option>
|
||
<option value="bounce">Bounce</option>
|
||
<option value="flip">Flip</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="editVcardError" class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="editVcardError"></div>
|
||
<div x-show="editVcardSuccess" class="mt-4 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 py-4 border-t shrink-0">
|
||
<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: Desactivar Usuario + VCards ───────────────────────────── -->
|
||
<div x-show="desactivarVcardsModal" 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>
|
||
<h3 class="text-base font-semibold text-orange-700">Inactivar VCards del usuario</h3>
|
||
<p class="text-xs text-gray-500 mt-0.5" x-text="desactivarVcardsUser ? (desactivarVcardsUser.name || desactivarVcardsUser.email) : ''"></p>
|
||
</div>
|
||
<button @click="desactivarVcardsModal = false" class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||
</div>
|
||
<div class="p-5 space-y-4">
|
||
<!-- Cargando vcards -->
|
||
<div x-show="desactivarVcardsLoading" class="flex items-center gap-2 text-sm text-gray-500">
|
||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
Cargando VCards del usuario...
|
||
</div>
|
||
<!-- Resumen de vcards encontradas -->
|
||
<div x-show="!desactivarVcardsLoading && desactivarVcardsItems.length > 0" class="p-3 bg-orange-50 border border-orange-200 rounded-lg text-sm">
|
||
<p class="font-medium text-orange-800 mb-2">
|
||
Se inactivarán <span class="font-bold" x-text="desactivarVcardsItems.length"></span> VCard(s):
|
||
</p>
|
||
<ul class="space-y-1 max-h-40 overflow-y-auto">
|
||
<template x-for="v in desactivarVcardsItems" :key="v.id || v._id">
|
||
<li class="flex items-center gap-2 text-xs text-orange-700">
|
||
<span class="text-orange-400">•</span>
|
||
<span class="font-medium" x-text="v.nombre || v.name || '—'"></span>
|
||
<span class="text-orange-500 font-mono" x-text="v.unico ? '/' + v.unico : ''"></span>
|
||
<span :class="v.estado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'"
|
||
class="ml-auto px-1.5 py-0.5 rounded-full text-xs"
|
||
x-text="v.estado ? 'Activa' : 'Inactiva'"></span>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
</div>
|
||
<!-- Sin vcards -->
|
||
<div x-show="!desactivarVcardsLoading && desactivarVcardsItems.length === 0" class="p-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-500">
|
||
Este usuario no tiene VCards activas para inactivar.
|
||
</div>
|
||
<p class="text-sm text-gray-700">¿Confirmas inactivar todas las VCards de este usuario? <span class="text-orange-600 font-medium">El usuario permanecerá activo.</span></p>
|
||
<!-- Error -->
|
||
<div x-show="desactivarVcardsError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="desactivarVcardsError"></div>
|
||
<!-- Resultado -->
|
||
<div x-show="desactivarVcardsResult" class="p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700" x-text="desactivarVcardsResult"></div>
|
||
</div>
|
||
<div class="flex justify-end gap-3 px-5 pb-5">
|
||
<button @click="desactivarVcardsModal = false" class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||
<button @click="confirmarDesactivarConVcards()" :disabled="saving || desactivarVcardsLoading"
|
||
class="text-sm px-4 py-2 rounded-lg text-white bg-orange-500 hover:bg-orange-600 disabled:opacity-50 flex items-center gap-2">
|
||
<svg x-show="saving" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
<span x-text="saving ? 'Inactivando...' : 'Inactivar VCards'"></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>
|
||
|
||
<!-- ─── Modal: Ver Plan ──────────────────────────────────────────────────── -->
|
||
<div x-show="verPlanModal" 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-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[90vh]">
|
||
<div class="flex justify-between items-center px-5 py-4 border-b shrink-0">
|
||
<div class="flex items-center gap-3">
|
||
<img x-show="verPlanData && verPlanData.img" :src="verPlanData && verPlanData.img ? 'https://vcard.u-site.app/' + verPlanData.img : null"
|
||
class="h-10 w-10 rounded-lg object-cover border" onerror="this.style.display='none'" />
|
||
<div>
|
||
<h2 class="text-lg font-semibold" x-text="verPlanData ? verPlanData.nombre : ''"></h2>
|
||
<span x-show="verPlanData"
|
||
:class="verPlanData && verPlanData.estado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||
class="text-xs px-2 py-0.5 rounded-full"
|
||
x-text="verPlanData && verPlanData.estado ? 'Activo' : 'Inactivo'"></span>
|
||
</div>
|
||
</div>
|
||
<button @click="verPlanModal = false" class="text-gray-400 hover:text-gray-600 text-xl shrink-0">✕</button>
|
||
</div>
|
||
<div class="overflow-y-auto p-5 space-y-5 flex-1" x-show="verPlanData">
|
||
<!-- Info general -->
|
||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">VCards permitidas</p>
|
||
<p x-text="verPlanData && verPlanData.cantidad || '—'"></p>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">ID</p>
|
||
<p class="font-mono text-xs truncate" x-text="verPlanData && (verPlanData._id || verPlanData.id) || '—'"></p>
|
||
</div>
|
||
<div x-show="verPlanData && verPlanData.plan_id_anual">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Plan anual (ID externo)</p>
|
||
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_id_anual || '—'"></p>
|
||
</div>
|
||
<div x-show="verPlanData && verPlanData.plan_id_mensual">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Plan mensual (ID externo)</p>
|
||
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_id_mensual || '—'"></p>
|
||
</div>
|
||
<div x-show="verPlanData && verPlanData.plan_anual_paypal">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">PayPal anual</p>
|
||
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_anual_paypal || '—'"></p>
|
||
</div>
|
||
<div x-show="verPlanData && verPlanData.plan_mensual_paypal">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">PayPal mensual</p>
|
||
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_mensual_paypal || '—'"></p>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Creado</p>
|
||
<p class="text-xs" x-text="verPlanData && verPlanData.created_at ? verPlanData.created_at.substring(0,10) : '—'"></p>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Actualizado</p>
|
||
<p class="text-xs" x-text="verPlanData && verPlanData.updated_at ? verPlanData.updated_at.substring(0,10) : '—'"></p>
|
||
</div>
|
||
</div>
|
||
<!-- Descripción -->
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Descripción</p>
|
||
<p class="text-sm text-gray-700" x-text="verPlanData && verPlanData.descripcion || '—'"></p>
|
||
</div>
|
||
<!-- Características (HTML) -->
|
||
<div x-show="verPlanData && verPlanData.caracteristicas">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Características</p>
|
||
<ul class="list-disc list-inside text-sm text-gray-700 space-y-0.5"
|
||
x-html="verPlanData && verPlanData.caracteristicas || ''"></ul>
|
||
</div>
|
||
<!-- Tarifas por país -->
|
||
<div x-show="verPlanData && verPlanData.tarifas && verPlanData.tarifas.length">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-2">Tarifas por país</p>
|
||
<div class="overflow-x-auto border rounded-lg">
|
||
<table class="w-full text-xs">
|
||
<thead class="bg-gray-50 border-b">
|
||
<tr>
|
||
<th class="py-2 px-3 text-left font-semibold text-gray-500">#</th>
|
||
<th class="py-2 px-3 text-left font-semibold text-gray-500">País ID</th>
|
||
<th class="py-2 px-3 text-left font-semibold text-gray-500">Monto</th>
|
||
<th class="py-2 px-3 text-left font-semibold text-gray-500">Actualizado</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="(t, i) in (verPlanData ? verPlanData.tarifas : [])" :key="t._id">
|
||
<tr class="border-b last:border-0 hover:bg-gray-50">
|
||
<td class="py-1.5 px-3 text-gray-400" x-text="i+1"></td>
|
||
<td class="py-1.5 px-3 font-mono text-gray-500 truncate max-w-[120px]" x-text="t.pais_id || '—'"></td>
|
||
<td class="py-1.5 px-3 font-medium">
|
||
<span :class="t.monto === 'Gratis' || t.monto === '0' ? 'text-green-600' : 'text-gray-800'"
|
||
x-text="t.monto === 'Gratis' || t.monto === '0' ? 'Gratis' : ('$' + t.monto)"></span>
|
||
</td>
|
||
<td class="py-1.5 px-3 text-gray-400" x-text="t.updated_at ? t.updated_at.substring(0,10) : '—'"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex justify-end gap-3 px-5 py-4 border-t shrink-0">
|
||
<button @click="verPlanModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cerrar</button>
|
||
<button @click="verPlanModal = false; openEditPlanModal(verPlanData)"
|
||
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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Modal: Editar Plan ────────────────────────────────────────────────── -->
|
||
<div x-show="editPlanModal" 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-xl shadow-2xl w-full max-w-xl mx-4 flex flex-col max-h-[90vh]">
|
||
<div class="flex justify-between items-center px-5 py-4 border-b shrink-0">
|
||
<h2 class="text-lg font-semibold">Editar Plan</h2>
|
||
<button @click="editPlanModal = false" class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||
</div>
|
||
<div class="overflow-y-auto p-5 flex-1">
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div class="sm:col-span-2">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre del plan</label>
|
||
<input x-model="editPlanForm.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 class="sm:col-span-2">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Descripción</label>
|
||
<textarea x-model="editPlanForm.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 class="sm:col-span-2">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Características (HTML)</label>
|
||
<textarea x-model="editPlanForm.caracteristicas" rows="4"
|
||
placeholder="<li>1 vCard</li> <li>Información personal</li>"
|
||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">VCards permitidas</label>
|
||
<input x-model="editPlanForm.cantidad" 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">Imagen (ruta storage)</label>
|
||
<input x-model="editPlanForm.img" type="text" placeholder="storage/archivo.png"
|
||
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">Plan ID anual (externo)</label>
|
||
<input x-model="editPlanForm.plan_id_anual" 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">Plan ID mensual (externo)</label>
|
||
<input x-model="editPlanForm.plan_id_mensual" 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">PayPal plan anual</label>
|
||
<input x-model="editPlanForm.plan_anual_paypal" 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">PayPal plan mensual</label>
|
||
<input x-model="editPlanForm.plan_mensual_paypal" 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 flex items-center gap-2 pt-1">
|
||
<input x-model="editPlanForm.estado" type="checkbox" id="planEstado" class="rounded" />
|
||
<label for="planEstado" class="text-sm text-gray-700 cursor-pointer">Plan activo</label>
|
||
</div>
|
||
</div>
|
||
<!-- Tarifas (sólo lectura) -->
|
||
<div x-show="editPlanData && editPlanData.tarifas && editPlanData.tarifas.length" class="mt-5">
|
||
<p class="text-xs font-medium text-gray-500 uppercase mb-2">Tarifas por país (sólo lectura)</p>
|
||
<div class="max-h-40 overflow-y-auto border rounded-lg">
|
||
<table class="w-full text-xs">
|
||
<thead class="bg-gray-50 border-b sticky top-0">
|
||
<tr>
|
||
<th class="py-1.5 px-3 text-left text-gray-500">País ID</th>
|
||
<th class="py-1.5 px-3 text-left text-gray-500">Monto</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="t in (editPlanData ? editPlanData.tarifas : [])" :key="t._id">
|
||
<tr class="border-b last:border-0">
|
||
<td class="py-1 px-3 font-mono text-gray-500 truncate max-w-[140px]" x-text="t.pais_id"></td>
|
||
<td class="py-1 px-3 font-medium"
|
||
:class="t.monto === 'Gratis' || t.monto === '0' ? 'text-green-600' : ''"
|
||
x-text="t.monto === 'Gratis' || t.monto === '0' ? 'Gratis' : ('$' + t.monto)"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div x-show="editPlanError" class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="editPlanError"></div>
|
||
<div x-show="editPlanSuccess" class="mt-4 p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700">✓ Plan actualizado correctamente.</div>
|
||
</div>
|
||
<div class="flex justify-end gap-3 px-5 py-4 border-t shrink-0">
|
||
<button @click="editPlanModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancelar</button>
|
||
<button @click="saveEditPlan()" :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>
|
||
|
||
</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: [],
|
||
|
||
// Ordenar usuarios por vencimiento
|
||
sortByVenc: false,
|
||
loadingVenc: false,
|
||
filterPlan: '',
|
||
knownPlans: [], // planes acumulados de todas las páginas cargadas
|
||
membresiaMap: {}, // { userId: { fecha_fin, activo, plan } }
|
||
|
||
// Inactivar VCards del usuario (sin desactivar usuario)
|
||
desactivarVcardsModal: false,
|
||
desactivarVcardsUser: null,
|
||
desactivarVcardsItems: [],
|
||
desactivarVcardsLoading: false,
|
||
desactivarVcardsError: '',
|
||
desactivarVcardsResult: '',
|
||
|
||
// Cache acumulado de usuarios de todas las páginas (para filtro de planes)
|
||
allUsersCache: {},
|
||
|
||
// 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 ver vcard (detalle completo)
|
||
verVcardModal: false,
|
||
verVcardData: null,
|
||
verVcardTab: 'info',
|
||
|
||
// Modal editar vcard
|
||
editVcardModal: false,
|
||
editVcardTab: 'datos',
|
||
editVcardData: null,
|
||
editVcardForm: {
|
||
nombre: '', cargo: '', descripcion: '', telefono: '', consecutivo_telefono: '',
|
||
whatsapp: '', consecutivo_whatsapp: '', email: '', empresa: '', direccion: '',
|
||
website: '', pais: '', ciudad: '', foto_perfil: '', logo: '',
|
||
privacidad: 'publica', boton: '', estado: true, es_borrador: false,
|
||
linkedin: '', twitter: '', facebook: '', instagram: '', tiktok: '',
|
||
github: '', behance: '', youtube: '', youtube_channel: '',
|
||
telegram: '', messenger: '', wechat: '', signal: '', consecutivo_signal: '', line: '',
|
||
cabecera: '#E8E8E8', fondo: '#2B2D35', color_cuerpo: '#FFFFFF',
|
||
color_iconos: '#2B2D35', color_info: '#2B2D35',
|
||
fuente: 'Inter', bordes: 'rounded-full', estilo_vcard: 1,
|
||
orden_iconos: 'top', animacion: '',
|
||
},
|
||
editVcardError: '',
|
||
editVcardSuccess: false,
|
||
|
||
// Modal ver plan
|
||
verPlanModal: false,
|
||
verPlanData: null,
|
||
|
||
// Modal editar plan
|
||
editPlanModal: false,
|
||
editPlanData: null,
|
||
editPlanForm: {
|
||
nombre: '', descripcion: '', caracteristicas: '', cantidad: '', img: '',
|
||
estado: true, plan_id_anual: '', plan_id_mensual: '',
|
||
plan_anual_paypal: '', plan_mensual_paypal: '',
|
||
},
|
||
editPlanError: '',
|
||
editPlanSuccess: 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;
|
||
this.verVcardModal = false;
|
||
this.verPlanModal = false;
|
||
this.editPlanModal = false;
|
||
this.desactivarVcardsModal = 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 = '';
|
||
this.filterPlan = '';
|
||
this.knownPlans = [];
|
||
this.allUsersCache = {};
|
||
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); this._collectPlanes(d.data); }
|
||
} else if (Array.isArray(d)) {
|
||
this.items = d;
|
||
this.total = d.length;
|
||
if (this.tab === 'usuarios') { this._collectRoles(d); this._collectPlanes(d); }
|
||
} else {
|
||
this.items = [];
|
||
this.total = 0;
|
||
this.errorMsg = 'Respuesta inesperada de la API.';
|
||
}
|
||
} catch(e) { this.errorMsg = e.message; }
|
||
this.loading = false;
|
||
// Si el sort por vencimiento estaba activo, re-aplicarlo con los nuevos items
|
||
if (this.sortByVenc && this.tab === 'usuarios' && this.items.length > 0) {
|
||
await this._fetchAndSortVenc();
|
||
}
|
||
},
|
||
|
||
_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 });
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
_collectPlanes(users) {
|
||
users.forEach(u => {
|
||
// Acumular en cache para filtrado cross-página
|
||
const uid = u.id || u._id;
|
||
if (uid) this.allUsersCache[uid] = u;
|
||
const nombre = (u.plan && u.plan.nombre) ? u.plan.nombre : '';
|
||
if (nombre && !this.knownPlans.includes(nombre)) {
|
||
this.knownPlans.push(nombre);
|
||
this.knownPlans.sort();
|
||
}
|
||
});
|
||
},
|
||
|
||
// ─── Ordenar por vencimiento ─────────────────────────────────────────
|
||
|
||
diasHastaVencer(u) {
|
||
const id = u.id || u._id;
|
||
const m = this.membresiaMap[id];
|
||
if (!m || !m.fecha_fin) return null;
|
||
const diff = new Date(m.fecha_fin) - new Date();
|
||
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||
},
|
||
|
||
vencimientoRowClass(u) {
|
||
const dias = this.diasHastaVencer(u);
|
||
if (dias === null) return 'hover:bg-gray-50';
|
||
if (dias <= 7) return 'bg-red-50 hover:bg-red-100';
|
||
if (dias <= 30) return 'bg-yellow-50 hover:bg-yellow-100';
|
||
return 'bg-green-50 hover:bg-green-100';
|
||
},
|
||
|
||
vencimientoBadgeClass(u) {
|
||
const dias = this.diasHastaVencer(u);
|
||
if (dias === null) return 'bg-gray-100 text-gray-400';
|
||
if (dias <= 0) return 'bg-red-200 text-red-800 font-semibold';
|
||
if (dias <= 7) return 'bg-red-100 text-red-700 font-semibold';
|
||
if (dias <= 30) return 'bg-yellow-100 text-yellow-700';
|
||
return 'bg-green-100 text-green-700';
|
||
},
|
||
|
||
vencimientoTexto(u) {
|
||
const id = u.id || u._id;
|
||
const m = this.membresiaMap[id];
|
||
if (this.loadingVenc && !m) return '...';
|
||
if (!m || !m.fecha_fin) return '—';
|
||
const dias = this.diasHastaVencer(u);
|
||
const fecha = m.fecha_fin.substring(0, 10);
|
||
if (dias <= 0) return `Vencido · ${fecha}`;
|
||
if (dias === 1) return `Mañana · ${fecha}`;
|
||
return `${dias}d · ${fecha}`;
|
||
},
|
||
|
||
async _fetchAndSortVenc() {
|
||
this.loadingVenc = true;
|
||
const batchSize = 5;
|
||
for (let i = 0; i < this.items.length; i += batchSize) {
|
||
const batch = this.items.slice(i, i + batchSize);
|
||
await Promise.all(batch.map(async u => {
|
||
const id = u.id || u._id;
|
||
if (this.membresiaMap[id]) return;
|
||
try {
|
||
const r = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
|
||
if (r.ok) { this.membresiaMap[id] = await r.json(); }
|
||
} catch(e) { /* ignorar errores individuales */ }
|
||
}));
|
||
}
|
||
this.loadingVenc = false;
|
||
this.items = [...this.items].sort((a, b) => {
|
||
const ma = this.membresiaMap[a.id || a._id];
|
||
const mb = this.membresiaMap[b.id || b._id];
|
||
const fa = ma?.fecha_fin ? new Date(ma.fecha_fin) : null;
|
||
const fb = mb?.fecha_fin ? new Date(mb.fecha_fin) : null;
|
||
if (!fa && !fb) return 0;
|
||
if (!fa) return 1;
|
||
if (!fb) return -1;
|
||
return fa - fb;
|
||
});
|
||
},
|
||
|
||
async toggleSortVenc() {
|
||
if (this.sortByVenc) {
|
||
this.sortByVenc = false;
|
||
this.loadTab();
|
||
return;
|
||
}
|
||
this.sortByVenc = true;
|
||
await this._fetchAndSortVenc();
|
||
},
|
||
|
||
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();
|
||
},
|
||
|
||
uniqueMontos(tarifas) {
|
||
const montos = [...new Set(tarifas.map(t => t.monto || '—'))];
|
||
return montos.slice(0, 5); // max 5 precios únicos
|
||
},
|
||
|
||
planesDisponibles() {
|
||
return this.knownPlans;
|
||
},
|
||
|
||
usuariosFiltrados() {
|
||
if (!this.filterPlan) return this.items;
|
||
// Filtrar desde el cache acumulado de TODAS las páginas cargadas
|
||
return Object.values(this.allUsersCache)
|
||
.filter(u => ((u.plan && u.plan.nombre) || '') === this.filterPlan);
|
||
},
|
||
|
||
// ─── Planes ──────────────────────────────────────────────────────────
|
||
openVerPlanModal(p) {
|
||
this.verPlanData = p;
|
||
this.verPlanModal = true;
|
||
},
|
||
|
||
openEditPlanModal(p) {
|
||
this.editPlanData = p;
|
||
this.editPlanForm = {
|
||
nombre: p.nombre || '',
|
||
descripcion: p.descripcion || '',
|
||
caracteristicas: p.caracteristicas || '',
|
||
cantidad: p.cantidad || '',
|
||
img: p.img || '',
|
||
estado: p.estado ?? true,
|
||
plan_id_anual: p.plan_id_anual || '',
|
||
plan_id_mensual: p.plan_id_mensual || '',
|
||
plan_anual_paypal: p.plan_anual_paypal || '',
|
||
plan_mensual_paypal: p.plan_mensual_paypal || '',
|
||
};
|
||
this.editPlanError = '';
|
||
this.editPlanSuccess = false;
|
||
this.editPlanModal = true;
|
||
},
|
||
|
||
async saveEditPlan() {
|
||
this.editPlanError = '';
|
||
this.editPlanSuccess = false;
|
||
const id = this.editPlanData._id || this.editPlanData.id;
|
||
const body = { ...this.editPlanForm };
|
||
// Limpiar campos vacíos opcionales
|
||
['plan_id_anual','plan_id_mensual','plan_anual_paypal','plan_mensual_paypal'].forEach(k => {
|
||
if (!body[k]) delete body[k];
|
||
});
|
||
this.saving = true;
|
||
try {
|
||
const r = await fetch(`/app/vcard-api/planes/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) {
|
||
this.editPlanError = d.error || d.message || 'Error al guardar el plan.';
|
||
} else {
|
||
this.editPlanSuccess = true;
|
||
// Actualiza la card local
|
||
const idx = this.items.findIndex(p => (p._id || p.id) === id);
|
||
if (idx > -1) this.items[idx] = { ...this.items[idx], ...body };
|
||
this.loadPlanes();
|
||
}
|
||
} catch(e) {
|
||
this.editPlanError = e.message;
|
||
}
|
||
this.saving = false;
|
||
},
|
||
|
||
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;
|
||
},
|
||
|
||
async openDesactivarConVcardsModal(u) {
|
||
this.desactivarVcardsUser = u;
|
||
this.desactivarVcardsItems = [];
|
||
this.desactivarVcardsError = '';
|
||
this.desactivarVcardsResult = '';
|
||
this.desactivarVcardsLoading = true;
|
||
this.desactivarVcardsModal = true;
|
||
try {
|
||
const id = u.id || u._id;
|
||
const r = await fetch(`/app/vcard-api/usuarios/${id}/vcards`);
|
||
const d = await r.json();
|
||
if (d.data && Array.isArray(d.data)) this.desactivarVcardsItems = d.data;
|
||
else if (Array.isArray(d)) this.desactivarVcardsItems = d;
|
||
} catch(e) { this.desactivarVcardsError = 'No se pudieron cargar las VCards: ' + e.message; }
|
||
this.desactivarVcardsLoading = false;
|
||
},
|
||
|
||
async confirmarDesactivarConVcards() {
|
||
this.desactivarVcardsError = '';
|
||
this.desactivarVcardsResult = '';
|
||
this.saving = true;
|
||
try {
|
||
// Solo inactivar cada VCard activa — no se toca al usuario
|
||
let inactivadas = 0;
|
||
const vcards = this.desactivarVcardsItems.filter(v => v.estado === true || v.estado === 1);
|
||
for (const v of vcards) {
|
||
const vid = v.id || v._id;
|
||
const r = await fetch(`/app/vcard-api/vcards/${vid}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ estado: false })
|
||
});
|
||
if (r.ok) inactivadas++;
|
||
}
|
||
this.desactivarVcardsResult = `VCards inactivadas: ${inactivadas} de ${vcards.length}. El usuario sigue activo.`;
|
||
setTimeout(async () => {
|
||
this.desactivarVcardsModal = false;
|
||
await this.loadTab();
|
||
}, 1800);
|
||
} catch(e) { this.desactivarVcardsError = e.message; }
|
||
this.saving = 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;
|
||
},
|
||
|
||
// ─── Ver VCard ───────────────────────────────────────────────────────
|
||
|
||
openVerVcardModal(v) {
|
||
this.verVcardData = v;
|
||
this.verVcardTab = 'info';
|
||
this.verVcardModal = true;
|
||
},
|
||
|
||
// ─── Edit VCard ──────────────────────────────────────────────────────
|
||
|
||
openEditVcardModal(v) {
|
||
this.editVcardData = v;
|
||
this.editVcardTab = 'datos';
|
||
this.editVcardForm = {
|
||
nombre: v.nombre || '',
|
||
cargo: v.cargo || '',
|
||
descripcion: v.descripcion || '',
|
||
telefono: v.telefono || '',
|
||
consecutivo_telefono: v.consecutivo_telefono || '',
|
||
whatsapp: v.whatsapp || '',
|
||
consecutivo_whatsapp: v.consecutivo_whatsapp || '',
|
||
email: v.email || '',
|
||
empresa: v.empresa || '',
|
||
direccion: v.direccion || '',
|
||
website: v.website || '',
|
||
pais: v.pais || '',
|
||
ciudad: v.ciudad || '',
|
||
foto_perfil: v.foto_perfil || '',
|
||
logo: v.logo || '',
|
||
privacidad: v.privacidad || 'publica',
|
||
boton: v.boton || '',
|
||
estado: !!v.estado,
|
||
es_borrador: !!v.es_borrador,
|
||
linkedin: v.linkedin || '',
|
||
twitter: v.twitter || '',
|
||
facebook: v.facebook || '',
|
||
instagram: v.instagram || '',
|
||
tiktok: v.tiktok || '',
|
||
github: v.github || '',
|
||
behance: v.behance || '',
|
||
youtube: v.youtube || '',
|
||
youtube_channel: v.youtube_channel || '',
|
||
telegram: v.telegram || '',
|
||
messenger: v.messenger || '',
|
||
wechat: v.wechat || '',
|
||
signal: v.signal || '',
|
||
consecutivo_signal: v.consecutivo_signal || '',
|
||
line: v.line || '',
|
||
cabecera: v.cabecera || '#E8E8E8',
|
||
fondo: v.fondo || '#2B2D35',
|
||
color_cuerpo: v.color_cuerpo || '#FFFFFF',
|
||
color_iconos: v.color_iconos || '#2B2D35',
|
||
color_info: v.color_info || '#2B2D35',
|
||
fuente: v.fuente || 'Inter',
|
||
bordes: v.bordes || 'rounded-full',
|
||
estilo_vcard: v.estilo_vcard || 1,
|
||
orden_iconos: v.orden_iconos || 'top',
|
||
animacion: v.animacion || '',
|
||
};
|
||
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;
|
||
// Cachear para el sort por vencimiento
|
||
this.membresiaMap[id] = 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>
|