This commit is contained in:
Lizandro Guarnizo
2026-05-06 15:29:14 -05:00
parent ccd37ff459
commit 1a8cc92a22
10 changed files with 908 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
<!-- 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">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="6" 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 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="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 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 -->
<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">El SaaS debe tener un Servicio vinculado para que se active al confirmar contratos.</p>
</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>
</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>
<!-- 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: {},
defaultForm() {
return { saas_id: 0, nombre: '', 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,
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,
};
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);
},
};
}
</script>
+96
View File
@@ -0,0 +1,96 @@
<!-- Vista: Logs de despacho SaaS — historial de notificaciones de pago -->
<div x-data="dispatchLogsApp()" x-init="init()" class="bg-white rounded-lg shadow">
<div class="container mx-auto p-6 w-full">
<div class="flex items-center gap-3 mb-1">
<a href="/app/saas-api" class="text-blue-500 hover:underline text-sm">← Integraciones</a>
</div>
<h1 class="text-2xl font-bold mb-1">Logs de despacho</h1>
<p class="text-sm text-gray-500 mb-4">Historial de notificaciones enviadas a SaaS externos al confirmar pagos.</p>
<!-- 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-3 border-b">Fecha</th>
<th class="py-2 px-3 border-b">Contrato</th>
<th class="py-2 px-3 border-b">Email</th>
<th class="py-2 px-3 border-b">Integración</th>
<th class="py-2 px-3 border-b">Fuente</th>
<th class="py-2 px-3 border-b">HTTP</th>
<th class="py-2 px-3 border-b">Estado</th>
<th class="py-2 px-3 border-b">Respuesta</th>
</tr>
</thead>
<tbody class="text-gray-500">
<template x-if="items.length === 0 && !loading">
<tr><td colspan="8" 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-3 text-xs whitespace-nowrap" x-text="fmtDate(item.CreatedAt)"></td>
<td class="py-2 px-3 font-mono text-xs" x-text="item.referencia || ('#' + item.contrato_id)"></td>
<td class="py-2 px-3 text-xs" x-text="item.payer_email || '-'"></td>
<td class="py-2 px-3 text-xs" x-text="item.SaasApiConfig ? item.SaasApiConfig.nombre : ('#' + item.saas_api_config_id)"></td>
<td class="py-2 px-3">
<span x-text="item.fuente"
:class="item.fuente === 'dlocal' ? 'bg-purple-100 text-purple-700' : item.fuente === 'bold' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'"
class="text-xs px-2 py-0.5 rounded-full capitalize"></span>
</td>
<td class="py-2 px-3 text-xs font-mono" x-text="item.http_status || '-'"></td>
<td class="py-2 px-3">
<span x-text="item.estado"
:class="item.estado === 'success' ? '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-3 max-w-xs">
<span x-show="!item.error_msg && item.respuesta" class="font-mono text-xs text-gray-600 truncate block" x-text="item.respuesta"></span>
<span x-show="item.error_msg" class="font-mono text-xs text-red-500 truncate block" x-text="item.error_msg"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Loading -->
<div x-show="loading" class="py-6 text-center text-gray-400 text-sm">Cargando…</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>
</div>
<script>
function dispatchLogsApp() {
return {
items: [],
total: 0, totalPages: 1, page: 1, limit: 30,
loading: false,
async init() { await this.load(1); },
async load(p) {
if (p < 1 || (this.totalPages > 0 && p > this.totalPages)) return;
this.loading = true;
this.page = p;
const res = await fetch(`/app/loadsaasdispatchlogs?page=${p}`);
const data = await res.json();
this.items = data.items || [];
this.total = data.total;
this.totalPages = data.totalPages;
this.loading = false;
},
fmtDate(s) {
if (!s) return '-';
const d = new Date(s);
return d.toLocaleDateString('es-CO') + ' ' + d.toLocaleTimeString('es-CO', {hour:'2-digit', minute:'2-digit'});
},
};
}
</script>