363 lines
21 KiB
HTML
363 lines
21 KiB
HTML
<!-- Vista: Configuración de Telegram -->
|
||
<div x-data="telegramApp()" 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">Notificaciones Telegram</h1>
|
||
<p class="text-sm text-gray-500 mb-4">
|
||
Configura bots de Telegram para enviar notificaciones dinámicas desde el panel.
|
||
Cada bot requiere un <code class="bg-gray-100 px-1 rounded">bot_token</code> y un <code class="bg-gray-100 px-1 rounded">chat_id</code> (puede ser un canal, grupo o usuario).
|
||
</p>
|
||
|
||
<!-- Tabs -->
|
||
<div class="flex gap-4 border-b mb-6 text-sm font-medium">
|
||
<button @click="tab='configs'"
|
||
:class="tab==='configs' ? 'border-b-2 border-[#8eb02f] text-[#8eb02f]' : 'text-gray-500 hover:text-gray-700'"
|
||
class="pb-2">Configuraciones</button>
|
||
<button @click="tab='send'"
|
||
:class="tab==='send' ? 'border-b-2 border-[#8eb02f] text-[#8eb02f]' : 'text-gray-500 hover:text-gray-700'"
|
||
class="pb-2">Enviar mensaje</button>
|
||
<button @click="tab='logs'; loadLogs(1)"
|
||
:class="tab==='logs' ? 'border-b-2 border-[#8eb02f] text-[#8eb02f]' : 'text-gray-500 hover:text-gray-700'"
|
||
class="pb-2">Historial</button>
|
||
</div>
|
||
|
||
<!-- ── Tab: Configuraciones ──────────────────────────────────────────── -->
|
||
<div x-show="tab==='configs'">
|
||
<div class="flex justify-end mb-4">
|
||
<button @click="openAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva configuración</button>
|
||
</div>
|
||
|
||
<template x-if="configs.length === 0">
|
||
<p class="text-gray-400 text-sm text-center py-8">Sin configuraciones. Agrega una para comenzar.</p>
|
||
</template>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<template x-for="cfg in configs" :key="cfg.ID">
|
||
<div class="border rounded-lg p-4 flex flex-col gap-2 hover:shadow-sm transition-shadow">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div>
|
||
<p class="font-semibold text-gray-800 text-sm" x-text="cfg.nombre"></p>
|
||
<p class="text-xs text-gray-400 mt-0.5">Chat ID: <span class="font-mono" x-text="cfg.chat_id"></span></p>
|
||
</div>
|
||
<span :class="cfg.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||
x-text="cfg.activo ? 'Activo' : 'Inactivo'"
|
||
class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap"></span>
|
||
</div>
|
||
<p x-show="cfg.notas" x-text="cfg.notas" class="text-xs text-gray-500 italic"></p>
|
||
<div class="flex gap-2 mt-auto pt-2 border-t flex-wrap">
|
||
<button @click="testConfig(cfg)" :disabled="testLoading[cfg.ID]"
|
||
class="flex-1 text-xs border border-[#8eb02f] text-[#8eb02f] px-3 py-1.5 rounded hover:bg-[#8eb02f] hover:text-white transition-colors disabled:opacity-50">
|
||
<span x-show="!testLoading[cfg.ID]">🔔 Probar</span>
|
||
<span x-show="testLoading[cfg.ID]">Enviando…</span>
|
||
</button>
|
||
<button @click="setWebhook(cfg)" :disabled="webhookLoading[cfg.ID]"
|
||
class="flex-1 text-xs border border-indigo-400 text-indigo-600 px-3 py-1.5 rounded hover:bg-indigo-50 transition-colors disabled:opacity-50"
|
||
title="Registra este bot para recibir mensajes del portal de clientes">
|
||
<span x-show="!webhookLoading[cfg.ID]">🔗 Webhook portal</span>
|
||
<span x-show="webhookLoading[cfg.ID]">Registrando…</span>
|
||
</button>
|
||
<button @click="openEdit(cfg)"
|
||
class="text-xs border border-blue-400 text-blue-500 px-3 py-1.5 rounded hover:bg-blue-50">Editar</button>
|
||
<button @click="confirmDelete(cfg.ID)"
|
||
class="text-xs border border-red-300 text-red-400 px-3 py-1.5 rounded hover:bg-red-50">Eliminar</button>
|
||
</div>
|
||
<p x-show="testResult[cfg.ID]" x-text="testResult[cfg.ID]"
|
||
:class="testOk[cfg.ID] ? 'text-green-600' : 'text-red-600'"
|
||
class="text-xs mt-1"></p>
|
||
<p x-show="webhookResult[cfg.ID]" x-text="webhookResult[cfg.ID]"
|
||
:class="webhookOk[cfg.ID] ? 'text-green-600' : 'text-red-600'"
|
||
class="text-xs mt-1"></p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Tab: Enviar mensaje ───────────────────────────────────────────── -->
|
||
<div x-show="tab==='send'" class="max-w-lg">
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Destinos <span class="text-red-500">*</span></label>
|
||
<div class="border rounded p-3 space-y-2 max-h-48 overflow-y-auto">
|
||
<template x-for="cfg in configs.filter(c => c.activo)" :key="cfg.ID">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" :value="cfg.ID" x-model.number="sendForm.config_ids" class="w-4 h-4">
|
||
<span class="text-sm" x-text="cfg.nombre"></span>
|
||
<span class="text-xs text-gray-400 font-mono ml-auto" x-text="cfg.chat_id"></span>
|
||
</label>
|
||
</template>
|
||
<p x-show="configs.filter(c => c.activo).length === 0" class="text-xs text-gray-400">No hay configuraciones activas.</p>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Título (opcional)</label>
|
||
<input type="text" x-model="sendForm.titulo" placeholder="Ej: Alerta de servidor caído"
|
||
class="border rounded w-full p-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Mensaje <span class="text-red-500">*</span></label>
|
||
<textarea x-model="sendForm.mensaje" rows="5"
|
||
placeholder="Escribe el mensaje. Puedes usar HTML básico: <b>negrita</b>, <i>cursiva</i>, <code>código</code>"
|
||
class="border rounded w-full p-2 text-sm"></textarea>
|
||
<p class="text-xs text-gray-400 mt-0.5">Soporta HTML básico de Telegram: <b>, <i>, <code>, <pre></p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<button @click="sendMessage()" :disabled="sendLoading"
|
||
class="bg-[#8eb02f] text-white px-5 py-2 rounded text-sm disabled:opacity-50">
|
||
<span x-show="!sendLoading">📤 Enviar</span>
|
||
<span x-show="sendLoading">Enviando…</span>
|
||
</button>
|
||
<p x-show="sendResult" x-text="sendResult"
|
||
:class="sendOk ? 'text-green-600' : 'text-red-600'"
|
||
class="text-sm"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Tab: Historial ────────────────────────────────────────────────── -->
|
||
<div x-show="tab==='logs'">
|
||
<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">Bot / Config</th>
|
||
<th class="py-2 px-4">Título</th>
|
||
<th class="py-2 px-4">Mensaje</th>
|
||
<th class="py-2 px-4">Estado</th>
|
||
<th class="py-2 px-4">Fecha</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="text-gray-500">
|
||
<template x-if="logs.length === 0">
|
||
<tr><td colspan="5" class="py-4 text-center text-gray-400">Sin registros</td></tr>
|
||
</template>
|
||
<template x-for="log in logs" :key="log.ID">
|
||
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||
<td class="py-2 px-4 text-xs" x-text="log.TelegramConfig ? log.TelegramConfig.nombre : '-'"></td>
|
||
<td class="py-2 px-4 text-xs" x-text="log.titulo || '-'"></td>
|
||
<td class="py-2 px-4 text-xs max-w-xs truncate" x-text="log.mensaje"></td>
|
||
<td class="py-2 px-4">
|
||
<span :class="log.estado === 'ok' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||
x-text="log.estado" class="text-xs px-2 py-0.5 rounded-full"></span>
|
||
<span x-show="log.error_msg" x-text="log.error_msg" class="text-xs text-red-500 ml-1"></span>
|
||
</td>
|
||
<td class="py-2 px-4 text-xs text-gray-400" x-text="fmtDate(log.CreatedAt)"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<!-- Paginación logs -->
|
||
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
|
||
<span>Total: <b x-text="logTotal"></b></span>
|
||
<div class="flex gap-1">
|
||
<button @click="loadLogs(logPage-1)" :disabled="logPage<=1" class="px-3 py-1 border rounded disabled:opacity-40">‹</button>
|
||
<span class="px-3 py-1" x-text="'Pág. ' + logPage + ' / ' + logTotalPages"></span>
|
||
<button @click="loadLogs(logPage+1)" :disabled="logPage>=logTotalPages" class="px-3 py-1 border rounded disabled:opacity-40">›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Modal Crear / Editar config ────────────────────────────────────────── -->
|
||
<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-md mx-4">
|
||
<div class="flex justify-between items-center p-5 border-b">
|
||
<h2 class="text-lg font-semibold" x-text="editMode ? 'Editar configuración' : 'Nueva configuración'"></h2>
|
||
<button @click="closeModal()" 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 <span class="text-red-500">*</span></label>
|
||
<input type="text" x-model="form.nombre" placeholder="Ej: Bot Alertas Producción"
|
||
class="border rounded w-full p-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Bot Token <span class="text-red-500">*</span></label>
|
||
<input type="password" x-model="form.bot_token"
|
||
:placeholder="editMode ? '••••••• (dejar vacío para no cambiar)' : 'Token del bot de @BotFather'"
|
||
class="border rounded w-full p-2 text-sm font-mono">
|
||
<p class="text-xs text-gray-400 mt-0.5">Obtén el token en <a href="https://t.me/BotFather" target="_blank" class="underline text-blue-500">@BotFather</a></p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Chat ID <span class="text-red-500">*</span></label>
|
||
<input type="text" x-model="form.chat_id" placeholder="Ej: -1001234567890 o @micanal"
|
||
class="border rounded w-full p-2 text-sm font-mono">
|
||
<p class="text-xs text-gray-400 mt-0.5">ID del chat, grupo o canal. Para canales usa el formato <code>@nombre_canal</code>.</p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Notas internas</label>
|
||
<textarea x-model="form.notas" rows="2" placeholder="Para qué se usa este bot..."
|
||
class="border rounded w-full p-2 text-sm"></textarea>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<input type="checkbox" id="tg-activo" x-model="form.activo" class="w-4 h-4">
|
||
<label for="tg-activo" class="text-sm">Activo</label>
|
||
</div>
|
||
<p x-show="formError" x-text="formError" 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>
|
||
|
||
<!-- ── Modal Confirmar eliminar ────────────────────────────────────────────── -->
|
||
<div x-show="deleteModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" style="display:none">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4 p-6">
|
||
<h2 class="text-lg font-bold mb-3">Confirmar eliminación</h2>
|
||
<p class="text-gray-600 mb-4 text-sm">¿Eliminar esta configuración de Telegram?</p>
|
||
<div class="flex justify-end gap-2">
|
||
<button @click="doDelete()" class="bg-red-500 text-white px-4 py-2 rounded text-sm">Eliminar</button>
|
||
<button @click="deleteModal=false" class="bg-gray-500 text-white px-4 py-2 rounded text-sm">Cancelar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function telegramApp() {
|
||
return {
|
||
tab: 'configs',
|
||
configs: [],
|
||
showModal: false, editMode: false, saving: false, formError: '',
|
||
deleteModal: false, deleteId: null,
|
||
form: { nombre: '', bot_token: '', chat_id: '', notas: '', activo: true },
|
||
testLoading: {}, testResult: {}, testOk: {},
|
||
webhookLoading: {}, webhookResult: {}, webhookOk: {},
|
||
sendForm: { config_ids: [], titulo: '', mensaje: '' },
|
||
sendLoading: false, sendResult: '', sendOk: true,
|
||
logs: [], logPage: 1, logTotalPages: 1, logTotal: 0,
|
||
|
||
async init() {
|
||
await this.loadConfigs();
|
||
},
|
||
|
||
async loadConfigs() {
|
||
const res = await fetch('/app/loadtelegram');
|
||
const data = await res.json();
|
||
this.configs = data;
|
||
// Pre-inicializar claves reactivas para cada config.
|
||
// Sin esto Alpine.js no detecta las asignaciones dinámicas cfg.ID -> value.
|
||
const tl = {}, tr = {}, to = {};
|
||
const wl = {}, wr = {}, wo = {};
|
||
for (const cfg of data) {
|
||
tl[cfg.ID] = false; tr[cfg.ID] = ''; to[cfg.ID] = false;
|
||
wl[cfg.ID] = false; wr[cfg.ID] = ''; wo[cfg.ID] = false;
|
||
}
|
||
this.testLoading = tl; this.testResult = tr; this.testOk = to;
|
||
this.webhookLoading = wl; this.webhookResult = wr; this.webhookOk = wo;
|
||
},
|
||
|
||
openAdd() {
|
||
this.editMode = false;
|
||
this.form = { nombre: '', bot_token: '', chat_id: '', notas: '', activo: true };
|
||
this.formError = '';
|
||
this.showModal = true;
|
||
},
|
||
|
||
openEdit(cfg) {
|
||
this.editMode = true;
|
||
this.form = { id: cfg.ID, nombre: cfg.nombre, bot_token: '', chat_id: cfg.chat_id, notas: cfg.notas || '', activo: cfg.activo };
|
||
this.formError = '';
|
||
this.showModal = true;
|
||
},
|
||
|
||
closeModal() { this.showModal = false; },
|
||
|
||
async save() {
|
||
this.formError = '';
|
||
if (!this.form.nombre.trim()) { this.formError = 'El nombre es requerido.'; return; }
|
||
if (!this.editMode && !this.form.bot_token.trim()) { this.formError = 'El bot_token es requerido.'; return; }
|
||
if (!this.form.chat_id.trim()) { this.formError = 'El chat_id es requerido.'; return; }
|
||
this.saving = true;
|
||
const url = this.editMode ? `/app/telegram/${this.form.id}` : '/app/telegram';
|
||
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.formError = d.error || 'Error al guardar.'; return; }
|
||
this.closeModal();
|
||
await this.loadConfigs();
|
||
},
|
||
|
||
confirmDelete(id) { this.deleteId = id; this.deleteModal = true; },
|
||
|
||
async doDelete() {
|
||
await fetch(`/app/telegram/${this.deleteId}`, { method: 'DELETE' });
|
||
this.deleteModal = false;
|
||
await this.loadConfigs();
|
||
},
|
||
|
||
async testConfig(cfg) {
|
||
this.testLoading[cfg.ID] = true;
|
||
this.testResult[cfg.ID] = '';
|
||
try {
|
||
const res = await fetch(`/app/telegram/${cfg.ID}/test`, { method: 'POST' });
|
||
const d = await res.json();
|
||
this.testOk[cfg.ID] = d.ok;
|
||
this.testResult[cfg.ID] = d.ok ? '✅ Mensaje enviado correctamente.' : ('❌ ' + (d.error || 'Error'));
|
||
} catch (e) {
|
||
this.testOk[cfg.ID] = false;
|
||
this.testResult[cfg.ID] = '❌ Error de red: ' + e.message;
|
||
} finally {
|
||
this.testLoading[cfg.ID] = false;
|
||
}
|
||
},
|
||
async setWebhook(cfg) {
|
||
this.webhookLoading[cfg.ID] = true;
|
||
this.webhookResult[cfg.ID] = '';
|
||
try {
|
||
const res = await fetch(`/app/telegram/${cfg.ID}/set-portal-webhook`, { method: 'POST' });
|
||
const d = await res.json();
|
||
this.webhookOk[cfg.ID] = d.ok;
|
||
this.webhookResult[cfg.ID] = d.ok
|
||
? '✅ Webhook registrado: ' + (d.webhook_url || '')
|
||
: '❌ Error: ' + (d.telegram?.description || d.error || 'Error desconocido');
|
||
} catch (e) {
|
||
this.webhookOk[cfg.ID] = false;
|
||
this.webhookResult[cfg.ID] = '❌ Error de red: ' + e.message;
|
||
} finally {
|
||
this.webhookLoading[cfg.ID] = false;
|
||
}
|
||
},
|
||
|
||
async sendMessage() {
|
||
this.sendResult = '';
|
||
if (!this.sendForm.mensaje.trim()) { this.sendResult = 'El mensaje no puede estar vacío.'; this.sendOk = false; return; }
|
||
if (!this.sendForm.config_ids.length) { this.sendResult = 'Selecciona al menos un destino.'; this.sendOk = false; return; }
|
||
this.sendLoading = true;
|
||
const res = await fetch('/app/telegram/send', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(this.sendForm),
|
||
});
|
||
const d = await res.json();
|
||
this.sendOk = d.ok;
|
||
this.sendResult = d.ok
|
||
? `✅ Enviado a ${d.enviados} destino(s).`
|
||
: `⚠️ Enviados: ${d.enviados}, Fallidos: ${d.fallidos}`;
|
||
this.sendLoading = false;
|
||
},
|
||
|
||
async loadLogs(p = 1) {
|
||
if (p < 1 || p > this.logTotalPages && this.logTotalPages > 0) return;
|
||
this.logPage = p;
|
||
const res = await fetch(`/app/telegram/logs?page=${p}`);
|
||
const d = await res.json();
|
||
this.logs = d.items || [];
|
||
this.logTotal = d.total;
|
||
this.logTotalPages = d.totalPages || 1;
|
||
},
|
||
|
||
fmtDate(s) {
|
||
if (!s) return '-';
|
||
const d = new Date(s);
|
||
return d.toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' });
|
||
},
|
||
};
|
||
}
|
||
</script>
|