Files
soft_usite/resources/views/saas_api.html
T
2026-05-16 21:41:44 -05:00

336 lines
21 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: Integraciones SaaS — Configuración de callbacks de pago -->
<div x-data="saasApiApp()" x-init="init()" @keydown.escape="closeModal()" class="bg-white rounded-lg shadow">
<div class="container mx-auto p-6 w-full">
<h1 class="text-2xl font-bold mb-1">Integraciones SaaS</h1>
<p class="text-sm text-gray-500 mb-4">
Configura los endpoints externos que recibirán la notificación cuando se confirme un pago.
La vinculación funciona así: <span class="font-mono text-xs">Contrato → Servicios → SaasProducto → Integración</span>
</p>
<div class="flex flex-col md:flex-row md:justify-between md:items-center gap-3 mb-4">
<a href="/app/saas-api/logs" class="text-sm text-blue-600 underline">Ver logs de despacho →</a>
<button @click="openAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva integración</button>
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="border-b border-gray-200 text-left font-semibold text-gray-600">
<tr>
<th class="py-2 px-4 border-b">Nombre</th>
<th class="py-2 px-4 border-b">SaaS</th>
<th class="py-2 px-4 border-b">Pasarela</th>
<th class="py-2 px-4 border-b">Endpoint</th>
<th class="py-2 px-4 border-b">Método</th>
<th class="py-2 px-4 border-b">Estado</th>
<th class="py-2 px-4 border-b w-24"></th>
</tr>
</thead>
<tbody class="text-gray-500">
<template x-if="items.length === 0 && !loading">
<tr><td colspan="7" class="py-4 text-center text-gray-400">Sin registros</td></tr>
</template>
<template x-for="item in items" :key="item.ID">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-4 font-medium text-gray-800" x-text="item.nombre"></td>
<td class="py-2 px-4 text-xs text-gray-600" x-text="item.SaasProducto ? item.SaasProducto.nombre : '-'"></td>
<td class="py-2 px-4">
<span x-text="item.pasarela || 'ambas'"
:class="{
'bg-purple-100 text-purple-700': item.pasarela === 'dlocal',
'bg-orange-100 text-orange-700': item.pasarela === 'bold',
'bg-gray-100 text-gray-600': !item.pasarela || item.pasarela === 'ambas'
}"
class="text-xs px-2 py-0.5 rounded-full capitalize"></span>
</td>
<td class="py-2 px-4">
<span class="font-mono text-xs text-blue-700 break-all" x-text="item.endpoint_url"></span>
</td>
<td class="py-2 px-4">
<span x-text="item.metodo" class="bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded font-mono"></span>
</td>
<td class="py-2 px-4">
<span x-text="item.activo ? 'Activo' : 'Inactivo'"
:class="item.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full"></span>
</td>
<td class="py-2 px-4 flex gap-2 justify-end">
<button @click="openProbar(item)" title="Probar endpoint" class="text-[#8eb02f] hover:text-[#6d8a24]">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<button @click="openEdit(item)" title="Editar" class="text-blue-500 hover:text-blue-700">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="confirmDelete(item.ID)" title="Eliminar" class="text-red-400 hover:text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
<span>Total: <b x-text="total"></b></span>
<div class="flex gap-1">
<button @click="load(page-1)" :disabled="page<=1" class="px-3 py-1 border rounded disabled:opacity-40"></button>
<span class="px-3 py-1" x-text="'Pág. ' + page + ' / ' + totalPages"></span>
<button @click="load(page+1)" :disabled="page>=totalPages" class="px-3 py-1 border rounded disabled:opacity-40"></button>
</div>
</div>
</div>
<!-- ── Modal Probar endpoint ─────────────────────────────────────────── -->
<div x-show="probarModal" 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-screen overflow-y-auto">
<div class="flex justify-between items-center p-5 border-b">
<div>
<h2 class="text-lg font-semibold">Probar endpoint</h2>
<p class="text-xs text-gray-500 mt-0.5" x-text="probarItem ? probarItem.metodo + ' ' + probarItem.endpoint_url : ''"></p>
</div>
<button @click="closeProbar()" 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">Payload JSON (editable)</label>
<textarea x-model="probarPayload" rows="8"
class="border rounded w-full p-2 text-sm font-mono"
placeholder='{"contrato_id":1,"email":"test@example.com"}'></textarea>
<p class="text-xs text-gray-400 mt-0.5">Se enviará tal cual al endpoint. Si está vacío se envía sin body.</p>
</div>
<button @click="runProbar()" :disabled="probarLoading" class="bg-[#8eb02f] text-white px-5 py-2 rounded text-sm disabled:opacity-50">
<span x-show="!probarLoading">▶ Enviar petición</span>
<span x-show="probarLoading">Enviando…</span>
</button>
<!-- Resultado -->
<div x-show="probarResult" class="border rounded">
<div class="flex items-center gap-3 px-4 py-2 border-b bg-gray-50">
<span class="text-sm font-semibold">HTTP</span>
<span x-text="probarResult && probarResult.http_status"
:class="probarResult && probarResult.ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full font-mono font-bold"></span>
<span class="text-xs text-gray-500" x-text="probarResult ? probarResult.latency_ms + ' ms' : ''"></span>
<span x-show="probarResult && !probarResult.ok" x-text="probarResult && probarResult.error" class="text-xs text-red-600 ml-auto"></span>
</div>
<pre x-text="probarResult && probarResult.body"
class="p-4 text-xs font-mono whitespace-pre-wrap break-all bg-gray-900 text-green-300 rounded-b max-h-64 overflow-auto"></pre>
</div>
</div>
<div class="flex justify-end p-5 border-t">
<button @click="closeProbar()" class="px-4 py-2 border rounded text-sm">Cerrar</button>
</div>
</div>
</div>
<!-- ── Modal crear / editar ────────────────────────────────────────────── -->
<div x-show="showModal" 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-screen overflow-y-auto">
<div class="flex justify-between items-center p-5 border-b">
<h2 class="text-lg font-semibold" x-text="editMode ? 'Editar integración' : 'Nueva integración'"></h2>
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="p-5 space-y-4">
<!-- Nombre -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre / etiqueta <span class="text-red-500">*</span></label>
<input type="text" x-model="form.nombre" class="border rounded w-full p-2 text-sm" placeholder="Ej: VCard confirmar pago">
</div>
<!-- SaaS + Pasarela -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Producto SaaS <span class="text-red-500">*</span></label>
<select x-model.number="form.saas_id" class="border rounded w-full p-2 text-sm">
<option value="">— Seleccionar —</option>
<template x-for="s in saasOpts" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
</template>
</select>
<p class="text-xs text-gray-400 mt-0.5">Debe tener un Servicio vinculado para activarse al confirmar contratos.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Pasarela de pago</label>
<select x-model="form.pasarela" class="border rounded w-full p-2 text-sm">
<option value="ambas">Ambas (Bold + dLocal)</option>
<option value="dlocal">Solo dLocal</option>
<option value="bold">Solo Bold</option>
</select>
<p class="text-xs text-gray-400 mt-0.5">Qué pasarela dispara esta notificación.</p>
</div>
</div>
<!-- Endpoint URL -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Endpoint URL <span class="text-red-500">*</span></label>
<input type="url" x-model="form.endpoint_url" class="border rounded w-full p-2 text-sm font-mono" placeholder="https://tu-saas.com/api/pagos/confirmar">
</div>
<!-- Método + Timeout -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Método HTTP</label>
<select x-model="form.metodo" class="border rounded w-full p-2 text-sm">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="GET">GET</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Timeout (segundos)</label>
<input type="number" x-model.number="form.timeout_seg" min="1" max="60" class="border rounded w-full p-2 text-sm">
</div>
</div>
<!-- API Key Header + Value -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Header de autenticación</label>
<input type="text" x-model="form.api_key_header" class="border rounded w-full p-2 text-sm font-mono" placeholder="Ej: X-API-Key">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Valor del token / API key</label>
<input type="password" x-model="form.api_key_value" class="border rounded w-full p-2 text-sm font-mono" placeholder="••••••••">
<p x-show="editMode" class="text-xs text-gray-400 mt-0.5">Dejar vacío para no cambiar.</p>
</div>
</div>
<!-- Payload Template -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payload JSON (template)</label>
<textarea x-model="form.payload_template" rows="5"
class="border rounded w-full p-2 text-sm font-mono"
placeholder='{"email":"{{.Email}}","contrato_id":{{.ContratoID}},"monto":{{.Monto}},"moneda":"{{.Moneda}}","fuente":"{{.Fuente}}"}'></textarea>
<p class="text-xs text-gray-400 mt-0.5">
Variables disponibles:
<code>{{.ContratoID}}</code> <code>{{.Referencia}}</code> <code>{{.Email}}</code>
<code>{{.Monto}}</code> <code>{{.Moneda}}</code> <code>{{.SaasID}}</code>
<code>{{.SaasSlug}}</code> <code>{{.Fuente}}</code> <code>{{.WebhookToken}}</code>
</p>
</div>
<!-- Activo -->
<div class="flex items-center gap-2">
<input type="checkbox" id="saasapi-activo" x-model="form.activo" class="w-4 h-4">
<label for="saasapi-activo" class="text-sm">Activo</label>
</div>
<!-- URL de webhook entrante (solo en edición) -->
<template x-if="editMode && form.webhook_token">
<div class="bg-slate-50 border border-slate-200 rounded-lg p-3">
<p class="text-xs font-semibold text-slate-600 mb-1">🔗 URL de webhook entrante (para que el SaaS llame a este sistema)</p>
<div class="flex items-center gap-2">
<code class="text-xs font-mono text-blue-700 break-all flex-1" x-text="window.location.origin + '/webhooks/saas-in/' + form.webhook_token"></code>
<button type="button" @click="navigator.clipboard.writeText(window.location.origin + '/webhooks/saas-in/' + form.webhook_token)" class="text-xs text-gray-500 hover:text-gray-800 flex-shrink-0 border rounded px-2 py-0.5">Copiar</button>
</div>
<p class="text-xs text-slate-400 mt-1">Acepta POST y GET. El token identifica a esta integración de forma única.</p>
</div>
</template>
<!-- Error -->
<p x-show="error" x-text="error" class="text-red-500 text-sm"></p>
</div>
<div class="flex justify-end gap-3 p-5 border-t">
<button @click="closeModal()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="save()" :disabled="saving" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm disabled:opacity-50">
<span x-show="!saving">Guardar</span>
<span x-show="saving">Guardando…</span>
</button>
</div>
</div>
</div>
</div>
<script>
function saasApiApp() {
return {
items: [], saasOpts: [],
total: 0, totalPages: 1, page: 1, limit: 20,
loading: false, showModal: false, editMode: false,
saving: false, error: '',
form: {},
probarModal: false, probarItem: null, probarPayload: '', probarResult: null, probarLoading: false,
defaultForm() {
return { saas_id: 0, nombre: '', pasarela: 'ambas', endpoint_url: '', metodo: 'POST', api_key_header: '', api_key_value: '', payload_template: '', timeout_seg: 10, activo: true };
},
async init() { await this.load(1); },
async load(p) {
if (p < 1 || p > this.totalPages && this.totalPages > 0) return;
this.loading = true;
this.page = p;
const res = await fetch(`/app/loadsaasapi?page=${p}`);
const data = await res.json();
this.items = data.items || [];
this.saasOpts = data.saas || [];
this.total = data.total;
this.totalPages = data.totalPages;
this.loading = false;
},
openAdd() {
this.editMode = false;
this.form = this.defaultForm();
this.error = '';
this.showModal = true;
},
openEdit(item) {
this.editMode = true;
this.form = {
id: item.ID,
saas_id: item.saas_id,
nombre: item.nombre,
pasarela: item.pasarela || 'ambas',
endpoint_url: item.endpoint_url,
metodo: item.metodo || 'POST',
api_key_header: item.api_key_header || '',
api_key_value: '', // no pre-llenar el secreto
payload_template: item.payload_template || '',
timeout_seg: item.timeout_seg || 10,
activo: item.activo,
webhook_token: item.webhook_token || '',
};
this.error = '';
this.showModal = true;
},
closeModal() { this.showModal = false; },
async save() {
this.error = '';
if (!this.form.nombre) { this.error = 'El nombre es requerido.'; return; }
if (!this.form.saas_id) { this.error = 'Selecciona un producto SaaS.'; return; }
if (!this.form.endpoint_url) { this.error = 'El endpoint URL es requerido.'; return; }
this.saving = true;
const url = this.editMode ? `/app/saas-api/${this.form.id}` : '/app/saas-api';
const method = this.editMode ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(this.form) });
this.saving = false;
if (!res.ok) { const d = await res.json(); this.error = d.error || 'Error al guardar.'; return; }
this.closeModal();
await this.load(this.page);
},
async confirmDelete(id) {
if (!confirm('¿Eliminar esta integración?')) return;
await fetch(`/app/saas-api/${id}`, { method: 'DELETE' });
await this.load(this.page);
},
openProbar(item) {
this.probarItem = item;
this.probarPayload = item.payload_template || '';
this.probarResult = null;
this.probarModal = true;
},
closeProbar() { this.probarModal = false; },
async runProbar() {
this.probarLoading = true;
this.probarResult = null;
const body = { payload: this.probarPayload };
const res = await fetch(`/app/saas-api/${this.probarItem.ID}/probar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
this.probarResult = await res.json();
this.probarLoading = false;
},
};
}
</script>