Files
soft_usite/resources/views/vcard_api.html
T
Lizandro Guarnizo 24e1cf1439 feat: add VCard API integration with configuration and proxy endpoints
- Implemented VCard API controller with methods to manage configuration (save, get).
- Added proxy functions to handle API requests for users, vcards, memberships, payments, transactions, logs, and miniwebs.
- Included error handling and response formatting for API interactions.
2026-05-19 20:08:50 -05:00

640 lines
38 KiB
HTML
Raw Blame History

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