up
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'); }
|
||||
},
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user