This commit is contained in:
Lizandro Guarnizo
2026-05-16 21:36:59 -05:00
parent 4fa506c7d6
commit 1511ca97d9
6 changed files with 114 additions and 50 deletions
+14
View File
@@ -116,3 +116,17 @@ func MarcarTodasLeidas(tipoUsuario string, usuarioID uint) error {
Where("tipo_usuario = ? AND (usuario_id = ? OR usuario_id = 0)", tipoUsuario, usuarioID).
Update("leida", true).Error
}
func CountUnreadFacturaNotifs(usuarioID uint) int64 {
var count int64
app.Http.Database.DB.Model(&SistemaNotificacion{}).
Where("tipo_usuario = 'portal_user' AND usuario_id = ? AND icono = '🧾' AND leida = false", usuarioID).
Count(&count)
return count
}
func MarcarNotifsFacturasLeidas(usuarioID uint) error {
return app.Http.Database.DB.Model(&SistemaNotificacion{}).
Where("tipo_usuario = 'portal_user' AND usuario_id = ? AND icono = '🧾'", usuarioID).
Update("leida", true).Error
}
+9 -4
View File
@@ -147,10 +147,15 @@ func DispatchFacturaSubida(factura *models.Factura) {
clienteNombre = factura.Cliente.Nombre
}
baseURL := getAppURL()
// Construir URL apuntando a la pestaña Facturas del proyecto (o dashboard si no tiene proyecto)
portalURL := baseURL + "/portal/dashboard"
if factura.ProyectoID != nil && factura.Proyecto != nil && factura.Proyecto.Slug != "" {
portalURL = baseURL + "/portal/proyecto/" + factura.Proyecto.Slug + "?tab=Facturas"
}
for _, u := range portalUsers {
u := u
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)
@@ -169,8 +174,8 @@ func DispatchFacturaSubida(factura *models.Factura) {
}
if cfg.CanalTelegram && u.TelegramChatID != "" {
ts := NewTelegramService()
msg := fmt.Sprintf("🧾 <b>Nueva factura disponible</b>\nNúmero: <b>%s</b>\nMonto: %s %.2f",
factura.Numero, factura.Moneda, factura.Monto)
msg := fmt.Sprintf("🧾 <b>Nueva factura disponible</b>\nNúmero: <b>%s</b>\nMonto: %s %.2f\n\n🔗 <a href=\"%s\">Ver en el portal</a>",
factura.Numero, factura.Moneda, factura.Monto, portalURL)
if err := ts.SendMessageWithToken(u.TelegramChatID, msg, getAdminBotToken()); err != nil {
log.Printf("[Notif] Error telegram portal_user %d: %v", u.ID, err)
}
+20 -16
View File
@@ -49,23 +49,27 @@
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</span>
</td>
<td class="px-4 py-3 flex items-center gap-1">
<template x-if="f.archivo">
<a :href="`/app/facturas/${f.ID}/download`" class="btn-icon text-blue-500" title="Descargar">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</template>
<button @click="openUpload(f)" class="btn-icon text-slate-500" title="Subir PDF">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg>
</button>
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<template x-if="f.archivo">
<a :href="`/app/facturas/${f.ID}/download`" class="btn-icon text-blue-500" title="Descargar">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</template>
<button @click="openUpload(f)" class="btn-icon text-slate-500" title="Subir PDF">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg>
</button>
</div>
</td>
<td class="px-4 py-3 flex items-center gap-2">
<button @click="openEdit(f)" class="btn-icon text-yellow-500">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="confirmDelete(f)" class="btn-icon text-red-500">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button @click="openEdit(f)" class="btn-icon text-yellow-500">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="confirmDelete(f)" class="btn-icon text-red-500">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
</template>
+52 -24
View File
@@ -25,12 +25,15 @@
<!-- 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; if(tab==='Tickets') marcarTicketsPendientesLeidos()" class="relative px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors"
<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>
<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>
<template x-if="tab === 'Facturas' && facturasNoLeidas > 0">
<span class="absolute -top-1 -right-1 min-w-[18px] h-[18px] bg-amber-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1" x-text="facturasNoLeidas > 9 ? '9+' : facturasNoLeidas"></span>
</template>
</button>
</template>
</div>
@@ -212,31 +215,32 @@
<!-- ─── FACTURAS ──────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Facturas'" x-cloak>
<div class="space-y-3">
{{ range .facturas }}
<div class="bg-white rounded-xl border border-slate-200 p-4 flex items-center gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-sm text-slate-800">{{ if .Numero }}{{ .Numero }}{{ else }}Factura #{{ .ID }}{{ end }}</span>
<span class="text-xs px-2 py-0.5 rounded-full font-semibold capitalize
{{ if eq .Estado "pagada" }}bg-green-100 text-green-700
{{ else if eq .Estado "vencida" }}bg-red-100 text-red-700
{{ else if eq .Estado "cancelada" }}bg-slate-100 text-slate-500
{{ else }}bg-yellow-100 text-yellow-700{{ end }}">{{ .Estado }}</span>
<template x-for="f in facturas" :key="f.ID">
<div class="bg-white rounded-xl border border-slate-200 p-4 flex items-center gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-sm text-slate-800" x-text="f.numero || ('Factura #' + f.ID)"></span>
<span class="text-xs px-2 py-0.5 rounded-full font-semibold capitalize"
:class="{
'bg-green-100 text-green-700': f.estado === 'pagada',
'bg-red-100 text-red-700': f.estado === 'vencida',
'bg-slate-100 text-slate-500': f.estado === 'cancelada',
'bg-yellow-100 text-yellow-700': !['pagada','vencida','cancelada'].includes(f.estado)
}" x-text="f.estado"></span>
<span x-show="f._nuevo" class="text-[10px] font-bold bg-amber-500 text-white px-1.5 py-0.5 rounded-full">Nuevo</span>
</div>
<p class="text-xs text-slate-500" x-text="f.descripcion"></p>
<p class="text-sm font-bold text-slate-700 mt-1" x-text="(f.moneda || '') + ' ' + Number(f.monto || 0).toLocaleString('es-CO')"></p>
<p x-show="f.fecha_vencimiento" class="text-xs text-slate-400" x-text="'Vence: ' + formatDate(f.fecha_vencimiento)"></p>
</div>
<p class="text-xs text-slate-500">{{ .Descripcion }}</p>
<p class="text-sm font-bold text-slate-700 mt-1">{{ .Moneda }} {{ .Monto }}</p>
{{ if .FechaVencimiento }}<p class="text-xs text-slate-400">Vence: {{ .FechaVencimiento.Format "02 Jan 2006" }}</p>{{ end }}
<template x-if="f.archivo">
<a :href="`/portal/facturas/${f.ID}/download`" class="text-[#8eb02f] hover:text-[#6d8c24] flex-shrink-0" title="Descargar factura">
<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>
</template>
</div>
{{ if .Archivo }}
<a href="/portal/facturas/{{ .ID }}/download" class="text-[#8eb02f] hover:text-[#6d8c24]">
<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>
{{ end }}
</div>
{{ end }}
{{ if not .facturas }}
<p class="text-slate-400 text-sm text-center py-10">Sin facturas disponibles.</p>
{{ end }}
</template>
<p x-show="!facturas.length && !loadingTickets" class="text-slate-400 text-sm text-center py-10">Sin facturas disponibles.</p>
</div>
</div>
@@ -253,9 +257,19 @@ function portalProyecto() {
loadingTickets: false,
creandoTicket: false,
ticketForm: { titulo:'', descripcion:'', prioridad:'media' },
facturas: [],
facturasNoLeidas: 0,
async init() {
// Soporte de URL param ?tab=Facturas
const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab');
if (tabParam) this.activeTab = tabParam;
await this.loadTickets();
// Si la tab activa es Facturas, marcar como leídas automáticamente
if (this.activeTab === 'Facturas') {
this.marcarFacturasLeidas();
}
},
async loadTickets() {
@@ -269,6 +283,12 @@ function portalProyecto() {
_noLeidos: (t.mensajes||[]).filter(m => m.es_admin && !m.leido_portal).length,
}));
this.recalcNoLeidos();
// Cargar facturas y conteo de no leídas
this.facturasNoLeidas = r.data.facturasNoLeidas || 0;
this.facturas = (r.data.facturas||[]).map(f => ({
...f,
_nuevo: this.facturasNoLeidas > 0,
}));
} catch(e){} finally { this.loadingTickets=false; }
},
@@ -302,6 +322,14 @@ function portalProyecto() {
this.recalcNoLeidos();
},
async marcarFacturasLeidas() {
if (this.facturasNoLeidas > 0) {
this.facturasNoLeidas = 0;
this.facturas.forEach(f => { f._nuevo = false; });
axios.post('/portal/mis-notifs/marcar-facturas').catch(()=>{});
}
},
async crearTicket() {
this.creandoTicket=true;
try {
+18 -6
View File
@@ -173,17 +173,29 @@ func PortalGetProyectoData(c *fiber.Ctx) error {
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
tickets, _ := models.GetTicketsByPortalUser(u.ID)
facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true)
facturasNoLeidas := models.CountUnreadFacturaNotifs(u.ID)
return c.JSON(fiber.Map{
"proyecto": proy,
"fases": fases,
"avances": avances,
"entregables": entregables,
"tickets": tickets,
"facturas": facturas,
"proyecto": proy,
"fases": fases,
"avances": avances,
"entregables": entregables,
"tickets": tickets,
"facturas": facturas,
"facturasNoLeidas": facturasNoLeidas,
})
}
// PortalMarcarFacturasLeidas marca las notificaciones de facturas como leídas.
func PortalMarcarFacturasLeidas(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
_ = models.MarcarNotifsFacturasLeidas(u.ID)
return c.JSON(fiber.Map{"ok": true})
}
// PortalCrearTicket crea un nuevo ticket de soporte.
func PortalCrearTicket(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
+1
View File
@@ -35,6 +35,7 @@ func PortalRoutes(app fiber.Router) {
portal.Get("/mis-notifs", controllers.PortalGetMisNotifs)
portal.Put("/mis-notifs/:id/leida", controllers.PortalMarcarNotifLeida)
portal.Post("/mis-notifs/marcar-todas", controllers.PortalMarcarTodasLeidas)
portal.Post("/mis-notifs/marcar-facturas", controllers.PortalMarcarFacturasLeidas)
// Partner: recursos y comunicados (solo usuarios con rol partner)
portal.Get("/partner-recursos", middlewares.PortalPartnerOnly(), controllers.PortalPartnerRecursos)