- soporte: el webhook de correo entrante era público sin ninguna validación; ahora exige una API key (query ?key= o header) comparada en tiempo constante. Además evita tickets duplicados por reintentos del proveedor (dedup por Message-Id) y enhebra respuestas del mismo remitente en vez de abrir un ticket nuevo por cada correo. - contabilidad: marcar una cuenta por cobrar/pagar como pagada ahora crea y vincula la Transaccion correspondiente (antes el dashboard de ingresos/ egresos nunca reflejaba esos pagos). Se corrige además que actualizar una cuenta por cobrar borraba su transaccion_id en cada PUT. - tareas: se activa por defecto el canal Telegram para tarea_asignada (estaba apagado desde el seed original) y se agrega un flujo real de vinculación de Telegram para el staff interno (código temporal + verificación), igual al que ya existía para los usuarios del portal — sin esto el chat_id de cada usuario había que pegarlo a mano y la notificación nunca llegaba. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
20 KiB
HTML
Executable File
341 lines
20 KiB
HTML
Executable File
<div x-data="modals" @keydown.escape="closeModal"
|
|
class="bg-white rounded-lg shadow max-w-2xl">
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-4 mb-6 pb-6 border-b border-slate-100">
|
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-xl font-bold flex-shrink-0"
|
|
style="background: linear-gradient(135deg, #8eb02f 0%, #6d8c24 100%)"
|
|
x-data="{n:'{{ .user.NombreUsuario }}'}"
|
|
x-text="n ? n.charAt(0).toUpperCase() : 'U'">
|
|
</div>
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">Mi perfil</h1>
|
|
<p class="text-xs text-slate-500 mt-0.5">Administra tu información personal y seguridad</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form @submit.prevent="submitForm" class="space-y-5">
|
|
<!-- Nombre -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1.5" for="name">Nombre completo</label>
|
|
<div class="relative">
|
|
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
|
<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="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/>
|
|
</svg>
|
|
</div>
|
|
<input type="text" id="name" x-model="Name" placeholder="Ingresa tu nombre completo"
|
|
class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none bg-white text-slate-800 transition-all"
|
|
style="box-shadow:none"
|
|
onfocus="this.style.borderColor='#8eb02f'; this.style.boxShadow='0 0 0 3px rgba(142,176,47,0.15)'"
|
|
onblur="this.style.borderColor='#e2e8f0'; this.style.boxShadow='none'"
|
|
required>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Email -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1.5" for="Email">Correo electrónico</label>
|
|
<div class="relative">
|
|
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
|
<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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>
|
|
</svg>
|
|
</div>
|
|
<input id="Email" x-model="Email" placeholder="correo@ejemplo.com" type="email"
|
|
class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none bg-white text-slate-800 transition-all"
|
|
style="box-shadow:none"
|
|
onfocus="this.style.borderColor='#8eb02f'; this.style.boxShadow='0 0 0 3px rgba(142,176,47,0.15)'"
|
|
onblur="this.style.borderColor='#e2e8f0'; this.style.boxShadow='none'"
|
|
required>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-3 pt-2">
|
|
<button type="submit"
|
|
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
|
|
style="background-color:#8eb02f"
|
|
onmouseover="if(!this.disabled) this.style.backgroundColor='#6d8c24'"
|
|
onmouseout="if(!this.disabled) this.style.backgroundColor='#8eb02f'"
|
|
:disabled="isLoading"
|
|
:style="isLoading ? 'opacity:0.7;cursor:not-allowed' : ''">
|
|
<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="M4.5 12.75l6 6 9-13.5"/>
|
|
</svg>
|
|
<span x-show="!isLoading">Guardar cambios</span>
|
|
<span x-show="isLoading">Guardando...</span>
|
|
</button>
|
|
<button type="button"
|
|
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold bg-slate-100 text-slate-700 hover:bg-slate-200 transition-colors"
|
|
x-on:click="showPasswordModal = true;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>
|
|
</svg>
|
|
Cambiar contraseña
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Telegram -->
|
|
<div class="mt-6 pt-6 border-t border-slate-100">
|
|
<h2 class="text-sm font-semibold text-slate-700 mb-1">Notificaciones por Telegram</h2>
|
|
<p class="text-xs text-slate-500 mb-4">Vincula tu Telegram para recibir avisos de tareas asignadas y otras notificaciones personales.</p>
|
|
|
|
<div x-show="tg.linked" class="flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg">
|
|
<svg class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
<div>
|
|
<p class="text-sm font-semibold text-green-800">Telegram vinculado</p>
|
|
<p class="text-xs text-green-600">Chat ID: <span class="font-mono" x-text="tg.chat_id"></span></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="!tg.linked" class="space-y-3">
|
|
<div class="flex gap-3">
|
|
<span class="flex-shrink-0 w-6 h-6 rounded-full text-white flex items-center justify-center text-xs font-bold" style="background-color:#8eb02f">1</span>
|
|
<div class="flex-1">
|
|
<p class="text-sm text-slate-700 font-medium">Genera tu código</p>
|
|
<button @click="tgGenerarCodigo()" :disabled="tg.generando"
|
|
class="mt-1.5 inline-flex items-center gap-1.5 px-3 py-1.5 text-white text-xs font-semibold rounded-lg transition-colors disabled:opacity-60"
|
|
style="background-color:#8eb02f">
|
|
<span x-show="!tg.generando">Generar código</span>
|
|
<span x-show="tg.generando">Generando…</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="tg.token" class="ml-9 p-3 bg-slate-800 rounded-lg">
|
|
<p class="text-xs text-slate-400 mb-1">Tu código de vinculación:</p>
|
|
<p class="text-2xl font-mono font-bold text-white tracking-widest" x-text="tg.token"></p>
|
|
</div>
|
|
|
|
<div x-show="tg.token" class="flex gap-3">
|
|
<span class="flex-shrink-0 w-6 h-6 rounded-full text-white flex items-center justify-center text-xs font-bold" style="background-color:#8eb02f">2</span>
|
|
<div class="flex-1">
|
|
<p class="text-sm text-slate-700 font-medium">Envía el código al bot</p>
|
|
<div class="flex items-center gap-2 p-2 bg-slate-100 rounded-lg mt-1">
|
|
<code class="text-sm font-mono text-slate-800" x-text="'/vincular ' + tg.token"></code>
|
|
</div>
|
|
<template x-if="tg.bot_username">
|
|
<a :href="tg.bot_link" target="_blank" class="inline-flex items-center gap-1 mt-2 text-xs font-semibold hover:underline" style="color:#8eb02f">
|
|
Abrir @<span x-text="tg.bot_username"></span>
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="tg.token" class="flex gap-3">
|
|
<span class="flex-shrink-0 w-6 h-6 rounded-full text-white flex items-center justify-center text-xs font-bold" style="background-color:#8eb02f">3</span>
|
|
<div class="flex-1">
|
|
<p class="text-sm text-slate-700 font-medium">Verifica la vinculación</p>
|
|
<button @click="tgVerificar()" :disabled="tg.verificando"
|
|
class="mt-1.5 inline-flex items-center gap-1.5 px-3 py-1.5 border text-xs font-semibold rounded-lg transition-colors disabled:opacity-60"
|
|
style="border-color:#8eb02f;color:#8eb02f">
|
|
<span x-show="!tg.verificando">Verificar vinculación</span>
|
|
<span x-show="tg.verificando">Verificando…</span>
|
|
</button>
|
|
<p x-show="tg.verMsg" x-text="tg.verMsg" :class="tg.verOk ? 'text-green-600' : 'text-red-500'" class="text-xs mt-2"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Modal cambiar contraseña -->
|
|
<div x-cloak x-show="showPasswordModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex z-50 items-center justify-center p-4">
|
|
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100">
|
|
<!-- Modal Header -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color:rgba(142,176,47,.1)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" style="color:#8eb02f" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-sm font-semibold text-slate-800">Nueva contraseña</h3>
|
|
</div>
|
|
<button @click="closeModal" class="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors">
|
|
<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="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Modal Body -->
|
|
<form @submit.prevent="changePass" class="px-6 py-5 space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1.5" for="password">Nueva contraseña</label>
|
|
<input type="password" id="password" x-model="password"
|
|
placeholder="Mínimo 8 caracteres"
|
|
class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm outline-none bg-white transition-all"
|
|
style="box-shadow:none"
|
|
onfocus="this.style.borderColor='#8eb02f'; this.style.boxShadow='0 0 0 3px rgba(142,176,47,0.15)'"
|
|
onblur="this.style.borderColor='#e2e8f0'; this.style.boxShadow='none'"
|
|
required>
|
|
</div>
|
|
<div class="flex gap-3 pt-1">
|
|
<button type="submit"
|
|
class="flex-1 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
|
|
style="background-color:#8eb02f"
|
|
onmouseover="this.style.backgroundColor='#6d8c24'"
|
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
|
Guardar
|
|
</button>
|
|
<button type="button"
|
|
class="flex-1 py-2.5 rounded-xl text-sm font-semibold bg-slate-100 text-slate-700 hover:bg-slate-200 transition-colors"
|
|
x-on:click="closeModal">
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<!-- Alpine.js Script -->
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('modals', () => ({
|
|
showPasswordModal: false,
|
|
Name: '',
|
|
Email: '',
|
|
password: '',
|
|
Usuario: '',
|
|
rolID: '',
|
|
Id: null,
|
|
isLoading: false,
|
|
usuarioID: '{{ .user.ID }}', // Inicializar directamente en el Alpine.js
|
|
tg: { linked: false, chat_id: '', token: '', bot_username: '', bot_link: '', generando: false, verificando: false, verMsg: '', verOk: false },
|
|
|
|
init() {
|
|
this.loadRegister();
|
|
this.loadTelegramStatus();
|
|
},
|
|
|
|
async loadTelegramStatus() {
|
|
try {
|
|
const r = await axios.get('/app/profile/telegram-status');
|
|
this.tg.linked = r.data.linked;
|
|
this.tg.chat_id = r.data.chat_id;
|
|
} catch (e) {}
|
|
},
|
|
|
|
async tgGenerarCodigo() {
|
|
this.tg.generando = true;
|
|
this.tg.token = '';
|
|
this.tg.verMsg = '';
|
|
try {
|
|
const r = await axios.post('/app/profile/telegram-init');
|
|
this.tg.token = r.data.token;
|
|
this.tg.bot_username = r.data.bot_username || '';
|
|
this.tg.bot_link = r.data.bot_link || '';
|
|
} catch (e) {
|
|
this.tg.verMsg = 'No se pudo generar el código. Intenta de nuevo.';
|
|
this.tg.verOk = false;
|
|
} finally { this.tg.generando = false; }
|
|
},
|
|
|
|
async tgVerificar() {
|
|
this.tg.verificando = true;
|
|
this.tg.verMsg = '';
|
|
try {
|
|
const r = await axios.post('/app/profile/telegram-validar');
|
|
if (r.data.ok) {
|
|
this.tg.linked = true;
|
|
this.tg.chat_id = r.data.chat_id;
|
|
this.tg.token = '';
|
|
this.tg.verMsg = '✅ ¡Telegram vinculado correctamente!';
|
|
this.tg.verOk = true;
|
|
} else {
|
|
this.tg.verMsg = r.data.error || 'No se encontró el mensaje.';
|
|
this.tg.verOk = false;
|
|
}
|
|
} catch (e) {
|
|
this.tg.verMsg = e.response?.data?.error || 'Error al verificar. Intenta de nuevo.';
|
|
this.tg.verOk = false;
|
|
} finally { this.tg.verificando = false; }
|
|
},
|
|
|
|
loadRegister() {
|
|
this.isLoading = true;
|
|
fetch(`/app/user/` + this.usuarioID)
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Error al cargar el usuario');
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
this.Name = data.user.name || '';
|
|
this.Email = data.user.email || '';
|
|
this.Usuario = data.user.nombre_usuario || '';
|
|
this.rolID = data.user.role_id || '';
|
|
})
|
|
.catch(error => alert(`Error: ${error.message}`)) // Manejo de errores
|
|
.finally(() => {
|
|
this.isLoading = false;
|
|
});
|
|
},
|
|
|
|
closeModal() {
|
|
this.showPasswordModal = false;
|
|
},
|
|
|
|
submitForm() {
|
|
this.isLoading = true;
|
|
fetch(`/app/users/` + this.usuarioID, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: this.Name,
|
|
email: this.Email,
|
|
nombre_usuario: this.Usuario,
|
|
role_id: this.rolID,
|
|
})
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Error al actualizar el usuario');
|
|
this.loadRegister();
|
|
return response.json();
|
|
})
|
|
.then(() => {
|
|
alert('Usuario actualizado con éxito');
|
|
})
|
|
.catch(error => alert(`Error: ${error.message}`))
|
|
.finally(() => {
|
|
this.isLoading = false;
|
|
});
|
|
},
|
|
|
|
changePass() {
|
|
this.isLoading = true;
|
|
fetch(`/app/password/` + this.usuarioID, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
password: this.password,
|
|
})
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Error al cambiar la contraseña');
|
|
return response.json();
|
|
})
|
|
.then(() => {
|
|
this.password = '';
|
|
this.closeModal();
|
|
alert('Contraseña cambiada con éxito');
|
|
})
|
|
.catch(error => alert(`Error: ${error.message}`))
|
|
.finally(() => {
|
|
this.isLoading = false;
|
|
});
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
</div>
|