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