This commit is contained in:
Lizandro Guarnizo
2026-05-19 13:09:06 -05:00
parent b1aa437bbd
commit 762ebc2afc
7 changed files with 143 additions and 5 deletions
+2
View File
@@ -28,6 +28,7 @@ type PortalUser struct {
Telefono string `json:"telefono" gorm:"column:telefono"`
Indicativo string `json:"indicativo" gorm:"column:indicativo"` // ej: +57, +1
Pais string `json:"pais" gorm:"column:pais"`
Ciudad string `json:"ciudad" gorm:"column:ciudad"`
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
@@ -93,6 +94,7 @@ func UpdatePortalUser(u *PortalUser) error {
"telefono": u.Telefono,
"indicativo": u.Indicativo,
"pais": u.Pais,
"ciudad": u.Ciudad,
"documento": u.Documento,
"empresa": u.Empresa,
"sitio_web": u.SitioWeb,
+7 -1
View File
@@ -210,6 +210,12 @@
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">Ciudad</label>
<input x-model="perfil.ciudad" type="text" placeholder="Ej: Bogotá"
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">RUT / Documento legal</label>
<input x-model="perfil.documento" type="text" placeholder="Ej: NIT, RUT, CC, CUIT..."
@@ -408,7 +414,7 @@ function miCuenta() {
open: false,
tab: 'perfil',
saving: false,
perfil: { nombre:'', email:'', telefono:'', indicativo:'', pais:'', documento:'', empresa:'', sitio_web:'', documento_rut_file:'', documento_rut_nombre:'' },
perfil: { nombre:'', email:'', telefono:'', indicativo:'', pais:'', ciudad:'', documento:'', empresa:'', sitio_web:'', documento_rut_file:'', documento_rut_nombre:'' },
perfilMsg: '', perfilOk: false,
pass: { actual:'', nueva:'', confirma:'' },
passMsg: '', passOk: false,
+38 -1
View File
@@ -24,7 +24,7 @@
<!-- Tabs -->
<div class="flex border-b border-slate-200 mb-6 gap-0 overflow-x-auto">
<template x-for="tab in ['Roadmap','Avances','Entregables','Tickets','Facturas']" :key="tab">
<template x-for="tab in ['Roadmap','Avances','Entregables','Documentos','Tickets','Facturas']" :key="tab">
<button @click="activeTab=tab; if(tab==='Tickets') marcarTicketsPendientesLeidos(); if(tab==='Facturas') marcarFacturasLeidas()" class="relative px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors"
:class="activeTab===tab ? 'font-semibold border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
<span x-text="tab"></span>
@@ -142,6 +142,41 @@
</div>
</div>
<!-- ─── DOCUMENTOS ─────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Documentos'" x-cloak>
<div class="space-y-3">
<template x-if="documentos.length === 0">
<p class="text-slate-400 text-sm text-center py-10">Sin documentos disponibles.</p>
</template>
<template x-for="d in documentos" :key="d.ID">
<div class="bg-white rounded-xl border border-slate-200 p-4 flex items-center gap-4">
<!-- Icono por tipo -->
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
:class="d.tipo==='contrato' ? 'bg-blue-50' : d.tipo==='orden_servicio' ? 'bg-amber-50' : 'bg-slate-100'">
<svg x-show="d.tipo==='contrato'" class="w-5 h-5 text-blue-500" 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>
<svg x-show="d.tipo==='orden_servicio'" class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
<svg x-show="d.tipo!=='contrato' && d.tipo!=='orden_servicio'" class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-slate-800 truncate" x-text="d.nombre"></p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium capitalize"
:class="d.tipo==='contrato' ? 'bg-blue-100 text-blue-700' : d.tipo==='orden_servicio' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-500'"
x-text="d.tipo?.replace('_',' ')"></span>
<span x-show="d.descripcion" class="text-xs text-slate-400 truncate" x-text="d.descripcion"></span>
</div>
</div>
<!-- Descarga -->
<a :href="`/portal/documentos/${d.ID}/download`"
class="flex-shrink-0 text-[#8eb02f] hover:text-[#6d8c24]" title="Descargar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</div>
</template>
</div>
</div>
<!-- ─── TICKETS ───────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Tickets'" x-cloak>
<!-- Crear ticket -->
@@ -259,6 +294,7 @@ function portalProyecto() {
ticketForm: { titulo:'', descripcion:'', prioridad:'media' },
facturas: [],
facturasNoLeidas: 0,
documentos: [],
async init() {
const params = new URLSearchParams(window.location.search);
@@ -299,6 +335,7 @@ function portalProyecto() {
...f,
_nuevo: this.facturasNoLeidas > 0,
}));
this.documentos = r.data.documentos || [];
} catch(e){} finally { this.loadingTickets=false; }
},
+34 -3
View File
@@ -111,6 +111,32 @@
<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>
<!-- Contacto -->
<div>
<label class="label">Teléfono</label>
<div class="flex gap-2">
<select x-model="form.indicativo" class="input-field w-28">
<option value="">Indicativo</option>
<option value="+57">🇨🇴 +57</option>
<option value="+1">🇺🇸 +1</option>
<option value="+52">🇲🇽 +52</option>
<option value="+54">🇦🇷 +54</option>
<option value="+56">🇨🇱 +56</option>
<option value="+51">🇵🇪 +51</option>
<option value="+58">🇻🇪 +58</option>
<option value="+593">🇪🇨 +593</option>
<option value="+34">🇪🇸 +34</option>
<option value="+55">🇧🇷 +55</option>
<option value="+44">🇬🇧 +44</option>
<option value="+1-other">Otro +1</option>
</select>
<input x-model="form.telefono" type="tel" class="input-field flex-1" placeholder="Número de teléfono">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="label">País</label><input x-model="form.pais" class="input-field w-full" placeholder="Ej: Colombia"></div>
<div><label class="label">Ciudad</label><input x-model="form.ciudad" class="input-field w-full" placeholder="Ej: Bogotá"></div>
</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">
@@ -241,6 +267,11 @@
<p class="text-sm" :class="detailUser?.pais ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.pais || '—'"></p>
</div>
<div>
<p class="label">Ciudad</p>
<p class="text-sm" :class="detailUser?.ciudad ? 'text-slate-700' : 'text-slate-300'"
x-text="detailUser?.ciudad || '—'"></p>
</div>
<div>
<p class="label">Sitio Web</p>
<template x-if="detailUser?.sitio_web">
@@ -353,7 +384,7 @@ function portalUsuariosApp() {
editId: null, deleteId: null, error: '',
accesoUserId: null, accesoClienteId: '',
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:'' },
form: { nombre:'', email:'', password:'', rol:'cliente', cliente_id:'', activo:true, notas:'', telegram_chat_id:'', sitio_web:'', telefono:'', indicativo:'', pais:'', ciudad:'' },
async init() { await this.load(); },
@@ -382,12 +413,12 @@ function portalUsuariosApp() {
openCreate() {
this.editId=null; this.error='';
this.form={nombre:'',email:'',password:'',rol:'cliente',cliente_id:'',activo:true,notas:'',telegram_chat_id:'',sitio_web:''};
this.form={nombre:'',email:'',password:'',rol:'cliente',cliente_id:'',activo:true,notas:'',telegram_chat_id:'',sitio_web:'',telefono:'',indicativo:'',pais:'',ciudad:''};
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||'',sitio_web:u.sitio_web||''};
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||'',telefono:u.telefono||'',indicativo:u.indicativo||'',pais:u.pais||'',ciudad:u.ciudad||''};
this.showModal=true;
},
async save() {
+45
View File
@@ -177,12 +177,14 @@ func PortalGetProyectoData(c *fiber.Ctx) error {
tickets, _ := models.GetTicketsByPortalUser(u.ID)
facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true)
facturasNoLeidas := models.CountUnreadFacturaNotifs(u.ID)
documentos, _ := models.GetDocumentosByProyecto(proy.ID)
return c.JSON(fiber.Map{
"proyecto": proy,
"fases": fases,
"avances": avances,
"entregables": entregables,
"documentos": documentos,
"tickets": tickets,
"facturas": facturas,
"facturasNoLeidas": facturasNoLeidas,
@@ -352,6 +354,46 @@ func PortalDownloadFactura(c *fiber.Ctx) error {
return c.SendFile(clean)
}
// PortalDownloadDocumento descarga un documento del proyecto (contrato, orden de servicio, etc.).
func PortalDownloadDocumento(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
docID, _ := c.ParamsInt("id")
doc, err := models.GetProyectoDocumentoByID(uint(docID))
if err != nil || doc.Archivo == "" {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
// Verificar que el proyecto pertenece al cliente del portal user
proy, err := models.GetProyectoByID(doc.ProyectoID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"})
}
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
hasAccess := false
for _, cid := range clienteIDs {
if cid == proy.ClienteID {
hasAccess = true
break
}
}
if !hasAccess {
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
}
clean := doc.Archivo
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
nombre := doc.OriginalName
if nombre == "" {
nombre = doc.Nombre
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre))
return c.SendFile(clean)
}
// ─── Notificaciones del portal user ──────────────────────────────────────────
func PortalGetMisNotifs(c *fiber.Ctx) error {
@@ -429,6 +471,7 @@ func PortalGetMiPerfil(c *fiber.Ctx) error {
"telefono": full.Telefono,
"indicativo": full.Indicativo,
"pais": full.Pais,
"ciudad": full.Ciudad,
"documento": full.Documento,
"empresa": full.Empresa,
"sitio_web": full.SitioWeb,
@@ -449,6 +492,7 @@ func PortalUpdateMiPerfil(c *fiber.Ctx) error {
Telefono string `json:"telefono"`
Indicativo string `json:"indicativo"`
Pais string `json:"pais"`
Ciudad string `json:"ciudad"`
Documento string `json:"documento"`
Empresa string `json:"empresa"`
SitioWeb string `json:"sitio_web"`
@@ -479,6 +523,7 @@ func PortalUpdateMiPerfil(c *fiber.Ctx) error {
full.Telefono = req.Telefono
full.Indicativo = req.Indicativo
full.Pais = req.Pais
full.Ciudad = req.Ciudad
full.Documento = req.Documento
full.Empresa = req.Empresa
full.SitioWeb = req.SitioWeb
@@ -52,6 +52,10 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
Notas string `json:"notas"`
TelegramChatID string `json:"telegram_chat_id"`
SitioWeb string `json:"sitio_web"`
Telefono string `json:"telefono"`
Indicativo string `json:"indicativo"`
Pais string `json:"pais"`
Ciudad string `json:"ciudad"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -88,6 +92,10 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
Notas: req.Notas,
TelegramChatID: req.TelegramChatID,
SitioWeb: req.SitioWeb,
Telefono: req.Telefono,
Indicativo: req.Indicativo,
Pais: req.Pais,
Ciudad: req.Ciudad,
}
if err := models.CreatePortalUser(u); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
@@ -112,6 +120,10 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
Notas string `json:"notas"`
TelegramChatID string `json:"telegram_chat_id"`
SitioWeb string `json:"sitio_web"`
Telefono string `json:"telefono"`
Indicativo string `json:"indicativo"`
Pais string `json:"pais"`
Ciudad string `json:"ciudad"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -137,6 +149,10 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
Notas: req.Notas,
TelegramChatID: req.TelegramChatID,
SitioWeb: req.SitioWeb,
Telefono: req.Telefono,
Indicativo: req.Indicativo,
Pais: req.Pais,
Ciudad: req.Ciudad,
}
u.ID = uint(id)
if err := models.UpdatePortalUser(u); err != nil {
+1
View File
@@ -30,6 +30,7 @@ func PortalRoutes(app fiber.Router) {
// Descargas
portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable)
portal.Get("/facturas/:id/download", controllers.PortalDownloadFactura)
portal.Get("/documentos/:id/download", controllers.PortalDownloadDocumento)
// Notificaciones in-app del portal user
portal.Get("/mis-notifs", controllers.PortalGetMisNotifs)