This commit is contained in:
Lizandro Guarnizo
2026-05-19 12:43:19 -05:00
parent 7f309186d6
commit d713dcd47b
5 changed files with 176 additions and 45 deletions
+2
View File
@@ -30,6 +30,7 @@ type PortalUser struct {
Pais string `json:"pais" gorm:"column:pais"`
Documento string `json:"documento" gorm:"column:documento"` // RUT, NIT, CC, etc.
Empresa string `json:"empresa" gorm:"column:empresa"` // empresa que representa
SitioWeb string `json:"sitio_web" gorm:"column:sitio_web"` // sitio web del partner/cliente
DocumentoRutFile string `json:"documento_rut_file" gorm:"column:documento_rut_file"` // ruta del archivo RUT
DocumentoRutNombre string `json:"documento_rut_nombre" gorm:"column:documento_rut_nombre"` // nombre original
PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"`
@@ -94,6 +95,7 @@ func UpdatePortalUser(u *PortalUser) error {
"pais": u.Pais,
"documento": u.Documento,
"empresa": u.Empresa,
"sitio_web": u.SitioWeb,
}).Error
}
+8 -1
View File
@@ -223,6 +223,13 @@
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Sitio Web</label>
<input x-model="perfil.sitio_web" type="url" placeholder="https://ejemplo.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
<p class="text-xs text-slate-400 mt-1">URL de tu sitio web (opcional).</p>
</div>
<!-- Documento RUT (archivo) -->
<div class="border border-slate-200 rounded-lg p-3 bg-slate-50">
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Documento RUT / Cámara de comercio (archivo)</label>
@@ -401,7 +408,7 @@ function miCuenta() {
open: false,
tab: 'perfil',
saving: false,
perfil: { nombre:'', email:'', telefono:'', indicativo:'', pais:'', documento:'', empresa:'', documento_rut_file:'', documento_rut_nombre:'' },
perfil: { nombre:'', email:'', telefono:'', indicativo:'', pais:'', documento:'', empresa:'', sitio_web:'', documento_rut_file:'', documento_rut_nombre:'' },
perfilMsg: '', perfilOk: false,
pass: { actual:'', nueva:'', confirma:'' },
passMsg: '', passOk: false,
+159 -44
View File
@@ -110,6 +110,7 @@
</div>
<div><label class="label">Notas</label><textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea></div>
<div><label class="label">Chat ID de Telegram</label><input x-model="form.telegram_chat_id" class="input-field w-full font-mono" placeholder="Ej: 123456789"><p class="text-xs text-slate-400 mt-1">Se usa para enviar notificaciones al usuario vía Telegram.</p></div>
<div><label class="label">Sitio Web</label><input x-model="form.sitio_web" class="input-field w-full" type="url" placeholder="https://ejemplo.com"><p class="text-xs text-slate-400 mt-1">Sitio web del partner o cliente.</p></div>
</div>
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
<div class="flex justify-end gap-3 mt-5">
@@ -150,6 +151,37 @@
</div>
</div>
<!-- Modal enviar credenciales -->
<div x-show="showCredModal" x-cloak class="fixed inset-0 z-[60] flex items-center justify-center bg-black/40">
<div @click.outside="showCredModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-[#8eb02f]/15 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-[#6d8c24]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</div>
<div>
<h2 class="text-base font-bold text-slate-800">Enviar credenciales de acceso</h2>
<p class="text-xs text-slate-500" x-text="credEmail"></p>
</div>
</div>
<p class="text-sm text-slate-600 mb-4">Se enviará un correo de bienvenida a <strong x-text="credNombre"></strong> con el enlace al portal y sus datos de inicio de sesión.</p>
<div class="mb-4">
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Contraseña a incluir en el correo</label>
<input x-model="credPassword" type="text" placeholder="Déjalo vacío para omitirla"
class="input-field w-full font-mono">
<p class="text-xs text-slate-400 mt-1">Si la dejas vacía se indicará al usuario que use la contraseña que configuraste.</p>
</div>
<p x-show="credMsg" x-text="credMsg"
:class="credOk ? 'text-green-600 bg-green-50 border border-green-200' : 'text-red-600 bg-red-50 border border-red-200'"
class="text-sm rounded-lg px-3 py-2 mb-3"></p>
<div class="flex justify-end gap-3">
<button @click="showCredModal=false" class="btn-secondary">Cancelar</button>
<button @click="enviarCredenciales()" :disabled="credSending"
class="btn-primary flex items-center gap-2"
x-text="credSending ? 'Enviando...' : 'Enviar correo'"></button>
</div>
</div>
</div>
<!-- Panel detalle usuario (slide derecho) -->
<div x-show="showDetail" x-cloak class="fixed inset-0 z-50 flex justify-end">
<div class="absolute inset-0 bg-black/40" @click="showDetail=false"></div>
@@ -194,54 +226,100 @@
<span x-show="detailUser?.role" class="badge badge-slate" x-text="detailUser?.role?.display_name || detailUser?.role?.name"></span>
</div>
<!-- Campos -->
<div class="grid grid-cols-2 gap-3">
<div x-show="detailUser?.telefono || detailUser?.indicativo" class="col-span-2">
<p class="label">Teléfono</p>
<p class="text-sm text-slate-700" x-text="(detailUser?.indicativo || '') + ' ' + (detailUser?.telefono || '')"></p>
</div>
<div x-show="detailUser?.pais">
<p class="label">País</p>
<p class="text-sm text-slate-700" x-text="detailUser?.pais"></p>
</div>
<div x-show="detailUser?.empresa">
<p class="label">Empresa</p>
<p class="text-sm text-slate-700" x-text="detailUser?.empresa"></p>
</div>
<div x-show="detailUser?.documento" class="col-span-2">
<p class="label">NIT / Documento</p>
<p class="text-sm text-slate-700 font-mono" x-text="detailUser?.documento"></p>
</div>
<div x-show="detailUser?.telegram_chat_id" class="col-span-2">
<p class="label">Telegram Chat ID</p>
<p class="text-sm text-slate-700 font-mono" x-text="detailUser?.telegram_chat_id"></p>
</div>
<div x-show="detailUser?.notas" class="col-span-2">
<p class="label">Notas</p>
<p class="text-sm text-slate-500 whitespace-pre-wrap" x-text="detailUser?.notas"></p>
<!-- Sección: Contacto -->
<div class="pt-2">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Contacto</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-3">
<div class="col-span-2">
<p class="label">Teléfono</p>
<p class="text-sm text-slate-700"
x-text="(detailUser?.indicativo || '') + ' ' + (detailUser?.telefono || '') || '—'"
:class="!(detailUser?.telefono) && 'text-slate-300'"></p>
</div>
<div>
<p class="label">País</p>
<p class="text-sm" :class="detailUser?.pais ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.pais || '—'"></p>
</div>
<div>
<p class="label">Sitio Web</p>
<template x-if="detailUser?.sitio_web">
<a :href="detailUser.sitio_web" target="_blank" rel="noopener noreferrer"
class="text-sm text-[#8eb02f] hover:underline break-all" x-text="detailUser.sitio_web"></a>
</template>
<template x-if="!detailUser?.sitio_web">
<p class="text-sm text-slate-300"></p>
</template>
</div>
</div>
</div>
<!-- Documento RUT (archivo) -->
<div x-show="detailUser?.documento_rut_file" class="pt-3 border-t border-slate-100">
<p class="label">Documento RUT / Cámara de comercio</p>
<div class="flex items-center gap-2 mt-1">
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="text-sm text-slate-600 truncate" x-text="detailUser?.documento_rut_nombre || 'Archivo RUT'"></span>
<a :href="`/app/portal-usuarios/${detailUser?.ID}/rut-file`" target="_blank"
class="text-[#8eb02f] text-xs font-semibold hover:underline flex-shrink-0">Descargar</a>
<!-- Sección: Empresa / Legal -->
<div class="pt-3 border-t border-slate-100">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Empresa / Legal</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<p class="label">Empresa</p>
<p class="text-sm" :class="detailUser?.empresa ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.empresa || '—'"></p>
</div>
<div>
<p class="label">NIT / Documento</p>
<p class="text-sm font-mono" :class="detailUser?.documento ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.documento || '—'"></p>
</div>
<div x-show="detailUser?.rol==='cliente'" class="col-span-2">
<p class="label">Cliente asignado</p>
<p class="text-sm" :class="detailUser?.cliente ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.cliente?.empresa || detailUser?.cliente?.nombre || '—'"></p>
</div>
</div>
</div>
<!-- Accesos partner -->
<!-- Sección: Canales -->
<div class="pt-3 border-t border-slate-100">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Canales</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-3">
<div class="col-span-2">
<p class="label">Telegram Chat ID</p>
<p class="text-sm font-mono" :class="detailUser?.telegram_chat_id ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.telegram_chat_id || '—'"></p>
</div>
</div>
</div>
<!-- Sección: Notas -->
<div class="pt-3 border-t border-slate-100">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Notas internas</p>
<p class="text-sm whitespace-pre-wrap" :class="detailUser?.notas ? 'text-slate-600' : 'text-slate-300'"
x-text="detailUser?.notas || 'Sin notas'"></p>
</div>
<!-- Sección: Documento RUT -->
<div class="pt-3 border-t border-slate-100">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Documento RUT / Cámara de comercio</p>
<template x-if="detailUser?.documento_rut_file">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="text-sm text-slate-600 truncate" x-text="detailUser?.documento_rut_nombre || 'Archivo RUT'"></span>
<a :href="`/app/portal-usuarios/${detailUser?.ID}/rut-file`" target="_blank"
class="text-[#8eb02f] text-xs font-semibold hover:underline flex-shrink-0">Descargar</a>
</div>
</template>
<template x-if="!detailUser?.documento_rut_file">
<p class="text-sm text-slate-300">Sin archivo adjunto</p>
</template>
</div>
<!-- Sección: Accesos partner -->
<div x-show="detailUser?.rol==='partner'" class="pt-3 border-t border-slate-100">
<p class="label">Accesos de cliente</p>
<div class="flex flex-wrap gap-1 mt-1">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Accesos de cliente</p>
<div class="flex flex-wrap gap-1">
<template x-for="acc in (detailUser?.portal_accesos || [])" :key="acc.ID">
<span class="bg-slate-100 text-slate-600 px-2 py-0.5 rounded text-xs"
x-text="acc.cliente?.empresa || acc.cliente?.nombre || acc.cliente_id"></span>
</template>
<span x-show="!(detailUser?.portal_accesos?.length)" class="text-slate-400 text-xs">Sin accesos asignados</span>
<span x-show="!(detailUser?.portal_accesos?.length)" class="text-slate-300 text-xs">Sin accesos asignados</span>
</div>
</div>
@@ -252,8 +330,12 @@
</div>
<!-- Footer -->
<div x-show="detailUser && !loadingDetail" class="flex-shrink-0 px-5 py-4 border-t border-slate-200 bg-slate-50 flex gap-2">
<div x-show="detailUser && !loadingDetail" class="flex-shrink-0 px-5 py-4 border-t border-slate-200 bg-slate-50 flex gap-2 flex-wrap">
<button @click="openEdit(detailUser); showDetail=false;" class="btn-secondary flex-1">Editar</button>
<button @click="openCredModal(detailUser)" class="flex-1 flex items-center justify-center gap-1.5 text-sm font-semibold px-3 py-2 rounded-lg border border-[#8eb02f] text-[#6d8c24] hover:bg-[#8eb02f]/10 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Enviar accesos
</button>
<button @click="showDetail=false" class="btn-primary flex-1">Cerrar</button>
</div>
@@ -270,7 +352,8 @@ function portalUsuariosApp() {
showDetail: false, detailUser: null, loadingDetail: false,
editId: null, deleteId: null, error: '',
accesoUserId: null, accesoClienteId: '',
form: { nombre:'', email:'', password:'', rol:'cliente', cliente_id:'', activo:true, notas:'', telegram_chat_id:'' },
showCredModal: false, credUserId: null, credNombre: '', credEmail: '', credPassword: '', credSending: false, credMsg: '', credOk: false,
form: { nombre:'', email:'', password:'', rol:'cliente', cliente_id:'', activo:true, notas:'', telegram_chat_id:'', sitio_web:'' },
async init() { await this.load(); },
@@ -299,25 +382,57 @@ function portalUsuariosApp() {
openCreate() {
this.editId=null; this.error='';
this.form={nombre:'',email:'',password:'',rol:'cliente',cliente_id:'',activo:true,notas:'',telegram_chat_id:''};
this.form={nombre:'',email:'',password:'',rol:'cliente',cliente_id:'',activo:true,notas:'',telegram_chat_id:'',sitio_web:''};
this.showModal=true;
},
openEdit(u) {
this.editId=u.ID; this.error='';
this.form={nombre:u.nombre,email:u.email,password:'',rol:u.rol,cliente_id:u.cliente_id||'',activo:u.activo,notas:u.notas||'',telegram_chat_id:u.telegram_chat_id||''};
this.form={nombre:u.nombre,email:u.email,password:'',rol:u.rol,cliente_id:u.cliente_id||'',activo:u.activo,notas:u.notas||'',telegram_chat_id:u.telegram_chat_id||'',sitio_web:u.sitio_web||''};
this.showModal=true;
},
async save() {
this.saving=true; this.error='';
const payload={...this.form};
const plainPassword = payload.password;
if(payload.cliente_id==='') payload.cliente_id=null;
try {
if(this.editId) await axios.put(`/app/portal-usuarios/${this.editId}`, payload);
else await axios.post('/app/portal-usuarios', payload);
this.showModal=false; await this.load();
if(this.editId) {
await axios.put(`/app/portal-usuarios/${this.editId}`, payload);
this.showModal=false;
} else {
const r = await axios.post('/app/portal-usuarios', payload);
this.showModal=false;
// Ofrecer envío de credenciales al crear
this.credUserId = r.data.ID;
this.credNombre = r.data.nombre;
this.credEmail = r.data.email;
this.credPassword = plainPassword;
this.credMsg = ''; this.credOk = false;
this.showCredModal = true;
}
await this.load();
} catch(e) { this.error=e.response?.data?.error||'Error al guardar'; }
finally { this.saving=false; }
},
openCredModal(u) {
this.credUserId = u.ID;
this.credNombre = u.nombre;
this.credEmail = u.email;
this.credPassword = '';
this.credMsg = ''; this.credOk = false;
this.showCredModal = true;
},
async enviarCredenciales() {
this.credSending = true; this.credMsg = '';
try {
const r = await axios.post(`/app/portal-usuarios/${this.credUserId}/send-credentials`, { password: this.credPassword });
this.credMsg = '✅ ' + (r.data.message || 'Correo enviado correctamente.');
this.credOk = true;
} catch(e) {
this.credMsg = e.response?.data?.error || 'Error al enviar el correo.';
this.credOk = false;
} finally { this.credSending = false; }
},
confirmDelete(u) { this.deleteId=u.ID; this.showDelete=true; },
async doDelete() {
this.saving=true;
+3
View File
@@ -431,6 +431,7 @@ func PortalGetMiPerfil(c *fiber.Ctx) error {
"pais": full.Pais,
"documento": full.Documento,
"empresa": full.Empresa,
"sitio_web": full.SitioWeb,
"documento_rut_file": full.DocumentoRutFile,
"documento_rut_nombre": full.DocumentoRutNombre,
})
@@ -450,6 +451,7 @@ func PortalUpdateMiPerfil(c *fiber.Ctx) error {
Pais string `json:"pais"`
Documento string `json:"documento"`
Empresa string `json:"empresa"`
SitioWeb string `json:"sitio_web"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -479,6 +481,7 @@ func PortalUpdateMiPerfil(c *fiber.Ctx) error {
full.Pais = req.Pais
full.Documento = req.Documento
full.Empresa = req.Empresa
full.SitioWeb = req.SitioWeb
if err := models.UpdatePortalUser(full); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
@@ -51,6 +51,7 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
Rol string `json:"rol"`
Notas string `json:"notas"`
TelegramChatID string `json:"telegram_chat_id"`
SitioWeb string `json:"sitio_web"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -86,6 +87,7 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
Activo: true,
Notas: req.Notas,
TelegramChatID: req.TelegramChatID,
SitioWeb: req.SitioWeb,
}
if err := models.CreatePortalUser(u); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
@@ -109,6 +111,7 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
Activo bool `json:"activo"`
Notas string `json:"notas"`
TelegramChatID string `json:"telegram_chat_id"`
SitioWeb string `json:"sitio_web"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -133,6 +136,7 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
Activo: req.Activo,
Notas: req.Notas,
TelegramChatID: req.TelegramChatID,
SitioWeb: req.SitioWeb,
}
u.ID = uint(id)
if err := models.UpdatePortalUser(u); err != nil {