This commit is contained in:
Lizandro Guarnizo
2026-05-16 18:47:40 -05:00
parent b6203c1a68
commit 4c14f4a641
5 changed files with 125 additions and 28 deletions
+13 -4
View File
@@ -25,10 +25,11 @@ func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
type TicketMensaje struct {
gorm.Model
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
}
func (TicketMensaje) TableName() string { return "ticket_mensajes" }
@@ -77,3 +78,11 @@ func GetAllTickets(estado string) ([]ProyectoTicket, error) {
err := db.Find(&items).Error
return items, err
}
// MarkTicketMessagesReadByPortal marca como leídos todos los mensajes de admin
// de un ticket para el portal user (cuando abre el ticket).
func MarkTicketMessagesReadByPortal(ticketID uint) error {
return app.Http.Database.DB.Model(&TicketMensaje{}).
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
Update("leido_portal", true).Error
}
+25 -10
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
@@ -17,7 +18,8 @@ func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.Porta
if cfg == nil {
return
}
ticketURL := "/app/tickets"
baseURL := getAppURL()
ticketURL := baseURL + "/app/tickets"
titulo := fmt.Sprintf("Nuevo ticket: %s", ticket.Titulo)
cuerpo := fmt.Sprintf("Cliente: %s\nProyecto: %s\n%s", ticket.AutorNombre, proyectoNombre, ticket.Descripcion)
@@ -37,8 +39,8 @@ func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.Porta
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("🎫 <b>Nuevo ticket</b>\nProyecto: <b>%s</b>\nCliente: %s\nTítulo: <b>%s</b>\n\n%s",
proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion)
msg := fmt.Sprintf("🎫 <b>Nuevo ticket</b>\nProyecto: <b>%s</b>\nCliente: %s\nTítulo: <b>%s</b>\n\n%s\n\n🔗 <a href=\"%s\">Ver tickets</a>",
proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion, ticketURL)
sendTelegramAdmin(msg)
}
}
@@ -53,7 +55,8 @@ func DispatchTicketRespuestaCliente(ticket *models.ProyectoTicket, contenido str
if cfg == nil {
return
}
ticketURL := "/app/tickets"
baseURL := getAppURL()
ticketURL := baseURL + "/app/tickets"
titulo := fmt.Sprintf("Respuesta de cliente en: %s", ticket.Titulo)
cuerpo := fmt.Sprintf("Cliente: %s\nProyecto: %s\n%s", ticket.AutorNombre, proyectoNombre, contenido)
@@ -73,8 +76,8 @@ func DispatchTicketRespuestaCliente(ticket *models.ProyectoTicket, contenido str
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("💬 <b>Respuesta de cliente</b>\nProyecto: <b>%s</b>\nCliente: %s\n\n%s",
proyectoNombre, ticket.AutorNombre, contenido)
msg := fmt.Sprintf("💬 <b>Respuesta de cliente</b>\nProyecto: <b>%s</b>\nCliente: %s\n\n%s\n\n🔗 <a href=\"%s\">Ver tickets</a>",
proyectoNombre, ticket.AutorNombre, contenido, ticketURL)
sendTelegramAdmin(msg)
}
}
@@ -96,7 +99,8 @@ func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido strin
return
}
portalURL := "/portal/dashboard"
baseURL := getAppURL()
portalURL := baseURL + "/portal/dashboard"
titulo := fmt.Sprintf("Respuesta en tu ticket: %s", ticket.Titulo)
cuerpo := fmt.Sprintf("El equipo respondió: %s", contenido)
@@ -115,8 +119,8 @@ func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido strin
}
if cfg.CanalTelegram && portalUser.TelegramChatID != "" {
ts := NewTelegramService()
msg := fmt.Sprintf("💬 <b>El equipo respondió tu ticket</b>\nProyecto: <b>%s</b>\nTicket: <b>%s</b>\n\n%s",
proyectoNombre, ticket.Titulo, contenido)
msg := fmt.Sprintf("💬 <b>El equipo respondió tu ticket</b>\nProyecto: <b>%s</b>\nTicket: <b>%s</b>\n\n%s\n\n🔗 <a href=\"%s\">Ver tu portal</a>",
proyectoNombre, ticket.Titulo, contenido, portalURL)
if err := ts.SendMessageWithToken(portalUser.TelegramChatID, msg, getAdminBotToken()); err != nil {
log.Printf("[Notif] Error telegram portal_user %d: %v", portalUser.ID, err)
}
@@ -145,7 +149,8 @@ func DispatchFacturaSubida(factura *models.Factura) {
for _, u := range portalUsers {
u := u
portalURL := "/portal/dashboard"
baseURL := getAppURL()
portalURL := baseURL + "/portal/dashboard"
titulo := fmt.Sprintf("Nueva factura disponible: %s", factura.Numero)
cuerpo := fmt.Sprintf("Factura %s por %s %.2f", factura.Numero, factura.Moneda, factura.Monto)
@@ -213,3 +218,13 @@ func sendTelegramAdmin(mensaje string) {
}
}
}
// getAppURL devuelve la URL base configurada en APP_URL (sin slash final).
func getAppURL() string {
u := app.Http.Server.Url
// quitar slash final si lo hay
if len(u) > 0 && u[len(u)-1] == '/' {
u = u[:len(u)-1]
}
return u
}
+67 -14
View File
@@ -25,9 +25,13 @@
<!-- 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">
<button @click="activeTab=tab" class="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'"
x-text="tab"></button>
<button @click="activeTab=tab; if(tab==='Tickets') marcarTicketsPendientesLeidos()" 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>
<template x-if="tab === 'Tickets' && ticketsNoLeidos > 0">
<span class="absolute -top-1 -right-1 min-w-[18px] h-[18px] bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1" x-text="ticketsNoLeidos > 9 ? '9+' : ticketsNoLeidos"></span>
</template>
</button>
</template>
</div>
@@ -159,12 +163,17 @@
<!-- Lista de tickets -->
<div class="space-y-3">
<template x-for="t in tickets" :key="t.ID">
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div class="p-4 flex items-start justify-between cursor-pointer" @click="t._open=!t._open">
<div class="bg-white rounded-xl border overflow-hidden" :class="t._noLeidos > 0 ? 'border-[#8eb02f]' : 'border-slate-200'">
<div class="p-4 flex items-start justify-between cursor-pointer" @click="abrirTicket(t)">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="badge" :class="ticketBadge(t.estado)" x-text="t.estado"></span>
<span class="badge" :class="prioBadge(t.prioridad)" x-text="t.prioridad"></span>
<template x-if="t._noLeidos > 0">
<span class="inline-flex items-center gap-1 text-[10px] font-bold bg-red-500 text-white px-1.5 py-0.5 rounded-full">
<span x-text="t._noLeidos"></span> nuevo<span x-show="t._noLeidos > 1">s</span>
</span>
</template>
</div>
<h4 class="font-semibold text-slate-800 text-sm" x-text="t.titulo"></h4>
<p class="text-xs text-slate-400" x-text="formatDate(t.CreatedAt)"></p>
@@ -172,22 +181,25 @@
<svg class="w-4 h-4 text-slate-400 transition-transform" :class="t._open ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>
</div>
<div x-show="t._open" class="border-t border-slate-100 px-4 pb-4">
<p class="text-sm text-slate-600 py-3 border-b border-slate-100" x-text="t.descripcion"></p>
<div class="space-y-2 mt-3">
<p class="text-sm text-slate-600 py-3 border-b border-slate-100 leading-relaxed" x-text="t.descripcion"></p>
<!-- Mensajes con scroll -->
<div class="overflow-y-auto space-y-2 mt-3 pr-1" style="max-height:260px" x-ref="msgBox">
<template x-for="m in (t.mensajes||[])" :key="m.ID">
<div class="flex" :class="m.es_admin ? 'justify-start' : 'justify-end'">
<div class="rounded-xl px-3 py-2 text-sm max-w-xs"
<div class="rounded-xl px-3 py-2 text-sm max-w-[75%]"
:class="m.es_admin ? 'bg-slate-100 text-slate-700' : 'text-white'" :style="m.es_admin ? '' : 'background:#8eb02f'">
<div class="text-xs font-semibold mb-0.5" x-text="m.autor_nombre" :style="m.es_admin ? 'color:#64748b' : 'color:rgba(255,255,255,.8)'"></div>
<span x-text="m.contenido"></span>
<p class="leading-snug break-words" x-text="m.contenido"></p>
<p class="text-[10px] mt-0.5 opacity-60 text-right" x-text="formatDate(m.CreatedAt)"></p>
</div>
</div>
</template>
</div>
<template x-if="t.estado !== 'cerrado' && t.estado !== 'resuelto'">
<div class="flex gap-2 mt-3">
<input x-model="t._reply" type="text" placeholder="Responder..." class="input-field flex-1 text-sm">
<button @click="responderTicket(t)" class="btn-primary text-xs">Enviar</button>
<div class="flex gap-2 mt-3 pt-3 border-t border-slate-100">
<input x-model="t._reply" type="text" placeholder="Responder..." class="input-field flex-1 text-sm"
@keydown.enter.prevent="responderTicket(t)">
<button @click="responderTicket(t)" class="btn-primary text-xs px-4">Enviar</button>
</div>
</template>
</div>
@@ -237,6 +249,7 @@ function portalProyecto() {
return {
activeTab: 'Roadmap',
tickets: [],
ticketsNoLeidos: 0,
loadingTickets: false,
creandoTicket: false,
ticketForm: { titulo:'', descripcion:'', prioridad:'media' },
@@ -249,15 +262,51 @@ function portalProyecto() {
this.loadingTickets = true;
try {
const r = await axios.get(`/portal/api/proyecto/${PROYECTO_SLUG}`);
this.tickets = (r.data.tickets||[]).map(t => ({...t, _open:false, _reply:''}));
this.tickets = (r.data.tickets||[]).map(t => ({
...t,
_open: false,
_reply: '',
_noLeidos: (t.mensajes||[]).filter(m => m.es_admin && !m.leido_portal).length,
}));
this.recalcNoLeidos();
} catch(e){} finally { this.loadingTickets=false; }
},
recalcNoLeidos() {
this.ticketsNoLeidos = this.tickets.reduce((sum, t) => sum + (t._noLeidos||0), 0);
},
async abrirTicket(t) {
t._open = !t._open;
if (t._open) {
if (t._noLeidos > 0) {
t._noLeidos = 0;
this.recalcNoLeidos();
axios.post(`/portal/tickets/${t.ID}/leido`).catch(()=>{});
}
// scroll al final de mensajes al abrir
await this.$nextTick();
const boxes = document.querySelectorAll('[x-ref="msgBox"]');
boxes.forEach(b => { b.scrollTop = b.scrollHeight; });
}
},
async marcarTicketsPendientesLeidos() {
// Al abrir el tab Tickets, si alguno está abierto y tiene no leídos, marcarlos
this.tickets.forEach(t => {
if (t._open && t._noLeidos > 0) {
t._noLeidos = 0;
axios.post(`/portal/tickets/${t.ID}/leido`).catch(()=>{});
}
});
this.recalcNoLeidos();
},
async crearTicket() {
this.creandoTicket=true;
try {
const r = await axios.post('/portal/tickets', { ...this.ticketForm, proyecto_slug: PROYECTO_SLUG });
this.tickets.unshift({...r.data, _open:false, _reply:''});
this.tickets.unshift({...r.data, _open:false, _reply:'', _noLeidos:0});
this.ticketForm={titulo:'',descripcion:'',prioridad:'media'};
} catch(e) {
alert(e.response?.data?.error || 'Error al crear ticket');
@@ -271,6 +320,10 @@ function portalProyecto() {
if(!t.mensajes) t.mensajes=[];
t.mensajes.push(r.data);
t._reply='';
// scroll al último mensaje
await this.$nextTick();
const boxes = document.querySelectorAll('[x-ref="msgBox"]');
boxes.forEach(b => { b.scrollTop = b.scrollHeight; });
} catch(e) { alert(e.response?.data?.error||'Error'); }
},
+19
View File
@@ -372,3 +372,22 @@ func PortalMarcarTodasLeidas(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"ok": true})
}
// PortalMarcarTicketLeido marca como leídos todos los mensajes de admin del ticket.
func PortalMarcarTicketLeido(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
ticketID, _ := c.ParamsInt("id")
// Verificar que el ticket pertenece al portal user
ticket, err := models.GetTicketByID(uint(ticketID))
if err != nil || ticket == nil {
return c.Status(404).JSON(fiber.Map{"error": "ticket no encontrado"})
}
if ticket.PortalUserID != u.ID {
return c.Status(403).JSON(fiber.Map{"error": "sin permiso"})
}
_ = models.MarkTicketMessagesReadByPortal(uint(ticketID))
return c.JSON(fiber.Map{"ok": true})
}
+1
View File
@@ -25,6 +25,7 @@ func PortalRoutes(app fiber.Router) {
// Tickets
portal.Post("/tickets", controllers.PortalCrearTicket)
portal.Post("/tickets/:id/mensaje", controllers.PortalResponderTicket)
portal.Post("/tickets/:id/leido", controllers.PortalMarcarTicketLeido)
// Descargas
portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable)