Agrega API de pagos externos: tokens por servicio, atados a pasarela e IP
Permite emitir credenciales (token hasheado + IP/CIDR opcional) desde
/app/pagos-externos para que aplicaciones de terceros pidan cobros a
través de Bold/dLocal/PayPal sin acceso a nada más del sistema:
- POST /api/v1/pagos-externos/solicitar genera el link de cobro real
usando solo las pasarelas habilitadas para ese servicio.
- Los webhooks existentes de Bold/dLocal/PayPal (firma obligatoria,
idempotentes) ahora también resuelven referencias "extpay-…" sin
tocar el flujo de contratos ("contrato-{id}").
- Al confirmarse el pago se notifica por webhook firmado (HMAC) y/o
Telegram, configurable por servicio.
- CRUD de servicios protegido con SoloAdmin; token y callback_secret
solo se muestran una vez, en DB se guardan hasheados.
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
<!-- Vista: Pagos externos — API para que apps de terceros pidan cobros -->
|
||||
<div x-data="pagosExternosApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
|
||||
|
||||
<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">Pagos externos</h1>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Tokens de API para que aplicaciones de terceros pidan cobros a través de Bold, dLocal o PayPal.</p>
|
||||
</div>
|
||||
<button x-show="tab === 'servicios'" @click="openAdd()"
|
||||
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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nuevo servicio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 border-b border-gray-200 mb-5">
|
||||
<button @click="tab = 'servicios'; loadServicios()"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition"
|
||||
:class="tab === 'servicios' ? 'border-[#8eb02f] text-[#5a7a1e]' : 'border-transparent text-gray-500 hover:text-gray-700'">
|
||||
Servicios
|
||||
</button>
|
||||
<button @click="tab = 'historial'; loadSolicitudes()"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition"
|
||||
:class="tab === 'historial' ? 'border-[#8eb02f] text-[#5a7a1e]' : 'border-transparent text-gray-500 hover:text-gray-700'">
|
||||
Historial de cobros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alerta -->
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
||||
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
|
||||
|
||||
<!-- ─── Tab: Servicios ─────────────────────────────────────────────── -->
|
||||
<div x-show="tab === 'servicios'">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<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">Token</th>
|
||||
<th class="py-2 px-3">Pasarelas</th>
|
||||
<th class="py-2 px-3">Notificación</th>
|
||||
<th class="py-2 px-3">Estado</th>
|
||||
<th class="py-2 px-3 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-if="servicios.length === 0">
|
||||
<tr><td colspan="6" class="py-8 text-center text-gray-400">Sin servicios registrados</td></tr>
|
||||
</template>
|
||||
<template x-for="item in servicios" :key="item.ID">
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-400" x-text="item.token_preview"></td>
|
||||
<td class="py-2 px-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="p in (item.pasarelas_habilitadas || '').split(',').filter(x => x.trim())" :key="p">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-[#e9f0cf] text-[#5a7a1e]" x-text="p.trim()"></span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500">
|
||||
<span x-show="item.notificar_webhook">🔗 Webhook</span>
|
||||
<span x-show="item.notificar_webhook && item.notificar_telegram"> + </span>
|
||||
<span x-show="item.notificar_telegram">✈️ Telegram</span>
|
||||
<span x-show="!item.notificar_webhook && !item.notificar_telegram" class="text-gray-300">Ninguna</span>
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
<span :class="item.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
x-text="item.activo ? 'Activo' : 'Inactivo'"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="openEdit(item)"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
|
||||
<button @click="confirmRegenerar(item.ID)"
|
||||
class="text-xs text-amber-600 hover:text-amber-800 font-medium transition">Regenerar token</button>
|
||||
<button @click="confirmDelete(item.ID)"
|
||||
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Tab: Historial ─────────────────────────────────────────────── -->
|
||||
<div x-show="tab === 'historial'">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<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 externa</th>
|
||||
<th class="py-2 px-3">Servicio</th>
|
||||
<th class="py-2 px-3">Pasarela</th>
|
||||
<th class="py-2 px-3">Monto</th>
|
||||
<th class="py-2 px-3">Estado</th>
|
||||
<th class="py-2 px-3">Notificado</th>
|
||||
<th class="py-2 px-3">Fecha</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-if="solicitudes.length === 0">
|
||||
<tr><td colspan="7" class="py-8 text-center text-gray-400">Sin cobros registrados todavía</td></tr>
|
||||
</template>
|
||||
<template x-for="s in solicitudes" :key="s.ID">
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-2 px-3 font-mono text-xs" x-text="s.referencia_externa"></td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500" x-text="s.servicio ? s.servicio.nombre : '—'"></td>
|
||||
<td class="py-2 px-3 text-xs" x-text="s.pasarela"></td>
|
||||
<td class="py-2 px-3 text-xs" x-text="s.monto + ' ' + s.moneda"></td>
|
||||
<td class="py-2 px-3">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700': s.estado === 'pagado',
|
||||
'bg-yellow-100 text-yellow-700': s.estado === 'pendiente',
|
||||
'bg-red-100 text-red-700': s.estado === 'fallido' || s.estado === 'expirado'
|
||||
}" x-text="s.estado"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500">
|
||||
<span x-show="s.callback_entregado">🔗</span>
|
||||
<span x-show="s.telegram_entregado">✈️</span>
|
||||
<span x-show="!s.callback_entregado && !s.telegram_entregado">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-400" x-text="new Date(s.CreatedAt).toLocaleString()"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear / editar servicio -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar servicio' : 'Nuevo servicio de pago externo'"></h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
|
||||
<input x-model="form.nombre" type="text" required placeholder="Ej: App de reservas"
|
||||
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-600 mb-2">Pasarelas habilitadas *</label>
|
||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
||||
<template x-for="p in ['bold', 'dlocal', 'paypal']" :key="p">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" :checked="form.pasarelas_habilitadas.includes(p)"
|
||||
@change="togglePasarela(p)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700 capitalize" x-text="p"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 mt-1">Si el que llama no indica cuál usar, se toma la única habilitada (o falla si hay varias).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">IPs permitidas</label>
|
||||
<textarea x-model="ipsText" rows="2" placeholder="Una por línea o separadas por coma. IP exacta o CIDR (ej: 190.10.20.30 o 190.10.0.0/16). Vacío = sin restricción."
|
||||
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 class="border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input x-model="form.notificar_webhook" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700">Notificar por webhook HTTP</span>
|
||||
</label>
|
||||
<div x-show="form.notificar_webhook" x-cloak>
|
||||
<input x-model="form.callback_url" type="url" placeholder="https://tu-app.com/webhooks/pagos"
|
||||
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-[10px] text-gray-400 mt-1">Se envía un POST firmado (header <code>X-Signature</code>, HMAC-SHA256 con el callback_secret) al confirmarse el pago.</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input x-model="form.notificar_telegram" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700">Notificar por Telegram</span>
|
||||
</label>
|
||||
<div x-show="form.notificar_telegram" x-cloak>
|
||||
<input x-model="form.telegram_chat_id" type="text" placeholder="Chat ID de Telegram"
|
||||
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>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input x-model="form.activo" type="checkbox" id="pe_activo" class="rounded" />
|
||||
<label for="pe_activo" class="text-sm text-gray-700">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="closeModal()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal token generado (se muestra una sola vez) -->
|
||||
<div x-show="tokenResult" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h2 class="text-lg font-bold mb-1">⚠️ Guarda estas credenciales ahora</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">No se volverán a mostrar completas. Si las pierdes, tendrás que regenerarlas.</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Token (Authorization: Bearer …)</label>
|
||||
<div class="flex gap-2">
|
||||
<input readonly :value="tokenResult && tokenResult.token" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono bg-gray-50">
|
||||
<button @click="copiar(tokenResult.token)" class="px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="tokenResult && tokenResult.callback_secret">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Callback secret (para verificar la firma del webhook saliente)</label>
|
||||
<div class="flex gap-2">
|
||||
<input readonly :value="tokenResult && tokenResult.callback_secret" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono bg-gray-50">
|
||||
<button @click="copiar(tokenResult.callback_secret)" class="px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-5">
|
||||
<button @click="tokenResult = null; load()" class="px-4 py-2 text-sm text-white rounded-lg" style="background-color:#8eb02f">Listo, ya lo guardé</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirmar regenerar token -->
|
||||
<div x-show="regenerarId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
||||
<p class="text-gray-700 font-semibold mb-1">¿Regenerar token?</p>
|
||||
<p class="text-xs text-gray-500 mb-5">El token actual dejará de funcionar de inmediato. Cualquier app que lo use quedará desconectada hasta que actualices el nuevo.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="regenerarId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
<button @click="doRegenerar()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition disabled:opacity-50">
|
||||
<span x-text="saving ? 'Regenerando...' : 'Regenerar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirmar eliminación -->
|
||||
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
||||
<p class="text-gray-700 font-semibold mb-1">¿Eliminar servicio?</p>
|
||||
<p class="text-xs text-gray-500 mb-5">El token dejará de funcionar de inmediato. Esta acción no se puede deshacer.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
|
||||
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function pagosExternosApp() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tab: 'servicios',
|
||||
servicios: [], solicitudes: [],
|
||||
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
|
||||
errorMsg: '', successMsg: '', formError: '',
|
||||
ipsText: '',
|
||||
form: { nombre: '', pasarelas_habilitadas: [], notificar_webhook: false, callback_url: '', notificar_telegram: false, telegram_chat_id: '', activo: true },
|
||||
|
||||
async init() { await this.loadServicios() },
|
||||
|
||||
async load() { await this.loadServicios() },
|
||||
|
||||
async loadServicios() {
|
||||
this.loading = true; this.errorMsg = ''
|
||||
const res = await fetch('/app/pagos-externos/servicios')
|
||||
const data = await res.json()
|
||||
this.loading = false
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
|
||||
this.servicios = data.items || []
|
||||
},
|
||||
|
||||
async loadSolicitudes() {
|
||||
this.loading = true; this.errorMsg = ''
|
||||
const res = await fetch('/app/pagos-externos/solicitudes')
|
||||
const data = await res.json()
|
||||
this.loading = false
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
|
||||
this.solicitudes = data.items || []
|
||||
},
|
||||
|
||||
togglePasarela(p) {
|
||||
const idx = this.form.pasarelas_habilitadas.indexOf(p)
|
||||
if (idx >= 0) { this.form.pasarelas_habilitadas.splice(idx, 1) } else { this.form.pasarelas_habilitadas.push(p) }
|
||||
},
|
||||
|
||||
openAdd() {
|
||||
this.editItem = null
|
||||
this.form = { nombre: '', pasarelas_habilitadas: [], notificar_webhook: false, callback_url: '', notificar_telegram: false, telegram_chat_id: '', activo: true }
|
||||
this.ipsText = ''
|
||||
this.formError = ''
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
openEdit(item) {
|
||||
this.editItem = item
|
||||
this.form = {
|
||||
nombre: item.nombre,
|
||||
pasarelas_habilitadas: (item.pasarelas_habilitadas || '').split(',').map(s => s.trim()).filter(s => s),
|
||||
notificar_webhook: item.notificar_webhook,
|
||||
callback_url: item.callback_url || '',
|
||||
notificar_telegram: item.notificar_telegram,
|
||||
telegram_chat_id: item.telegram_chat_id || '',
|
||||
activo: item.activo,
|
||||
}
|
||||
this.ipsText = (item.ips_permitidas || '').split(',').map(s => s.trim()).filter(s => s).join('\n')
|
||||
this.formError = ''
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
closeModal() { this.showModal = false; this.editItem = null; this.formError = '' },
|
||||
|
||||
async save() {
|
||||
this.saving = true; this.formError = ''
|
||||
const ips = this.ipsText.split(/[\n,]/).map(s => s.trim()).filter(s => s)
|
||||
const payload = { ...this.form, ips_permitidas: ips }
|
||||
const url = this.editItem ? `/app/pagos-externos/servicios/${this.editItem.ID}` : '/app/pagos-externos/servicios'
|
||||
const method = this.editItem ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
||||
const data = await res.json()
|
||||
this.saving = false
|
||||
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
|
||||
this.closeModal()
|
||||
if (data.token) {
|
||||
this.tokenResult = data
|
||||
} else {
|
||||
this.showSuccess('Servicio actualizado')
|
||||
await this.loadServicios()
|
||||
}
|
||||
},
|
||||
|
||||
confirmRegenerar(id) { this.regenerarId = id },
|
||||
|
||||
async doRegenerar() {
|
||||
this.saving = true
|
||||
const res = await fetch(`/app/pagos-externos/servicios/${this.regenerarId}/regenerar-token`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
this.saving = false
|
||||
this.regenerarId = null
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error regenerando token'; return }
|
||||
this.tokenResult = data
|
||||
},
|
||||
|
||||
confirmDelete(id) { this.deleteId = id },
|
||||
|
||||
async doDelete() {
|
||||
this.saving = true
|
||||
const res = await fetch(`/app/pagos-externos/servicios/${this.deleteId}`, { method: 'DELETE' })
|
||||
this.saving = false
|
||||
this.deleteId = null
|
||||
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
|
||||
this.showSuccess('Servicio eliminado')
|
||||
await this.loadServicios()
|
||||
},
|
||||
|
||||
copiar(texto) {
|
||||
if (!texto) return
|
||||
navigator.clipboard.writeText(texto)
|
||||
this.showSuccess('Copiado al portapapeles')
|
||||
},
|
||||
|
||||
showSuccess(msg) {
|
||||
this.successMsg = msg
|
||||
setTimeout(() => { this.successMsg = '' }, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user