up
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -85,3 +86,19 @@ func GetAllLogs(limit, offset int) ([]NotificacionLog, int64, error) {
|
||||
func GetLogByID(id uint) (*NotificacionLog, error) {
|
||||
return GetNotificacionLogByID(id)
|
||||
}
|
||||
|
||||
// GetLogsByContratoID devuelve los logs de notificaciones que incluyen un contrato específico
|
||||
func GetLogsByContratoID(contratoID uint) ([]NotificacionLog, error) {
|
||||
var items []NotificacionLog
|
||||
// contratos_ids es un JSON array, buscamos que contenga el ID
|
||||
pattern := fmt.Sprintf(`%%[%d]%%`, contratoID)
|
||||
if err := app.Http.Database.DB.
|
||||
Preload("Regla").
|
||||
Where("contratos_ids LIKE ?", pattern).
|
||||
Order("created_at DESC").
|
||||
Limit(50).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -120,3 +120,16 @@ func GetSaasDispatchLogs(limit, offset int) ([]SaasDispatchLog, int64, error) {
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetDispatchLogsByContratoID devuelve los pagos/dispatches de un contrato específico
|
||||
func GetDispatchLogsByContratoID(contratoID uint) ([]SaasDispatchLog, error) {
|
||||
var items []SaasDispatchLog
|
||||
if err := app.Http.Database.DB.
|
||||
Where("contrato_id = ?", contratoID).
|
||||
Order("created_at DESC").
|
||||
Limit(50).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -273,6 +273,36 @@ func ReenviarLog(logEntry *models.NotificacionLog) error {
|
||||
return sendErr
|
||||
}
|
||||
|
||||
// EnviarCorreoConfirmacionPago envía el correo de "pago recibido" para un contrato.
|
||||
// Usa la regla/plantilla con tipo_evento = 'pago_recibido'. Si no existe, no hace nada.
|
||||
// Es idempotente: no reenvía si ya se envió hoy (YaEnviadoHoy por regla+cliente).
|
||||
func EnviarCorreoConfirmacionPago(contratoID uint) {
|
||||
contrato, err := models.GetContratoByID(contratoID)
|
||||
if err != nil {
|
||||
log.Printf("[PAGO-CORREO] contrato %d no encontrado: %v", contratoID, err)
|
||||
return
|
||||
}
|
||||
|
||||
reglas, err := models.GetReglasByTipoEvento("pago_recibido")
|
||||
if err != nil || len(reglas) == 0 {
|
||||
log.Printf("[PAGO-CORREO] sin regla pago_recibido configurada — se omite correo de confirmación")
|
||||
return
|
||||
}
|
||||
regla := ®las[0]
|
||||
|
||||
// Idempotencia: si ya se envió hoy para esta regla+cliente, no reenviar
|
||||
if models.YaEnviadoHoy(contrato.ClienteID, regla.ID) {
|
||||
log.Printf("[PAGO-CORREO] correo de confirmación ya enviado hoy para cliente %d", contrato.ClienteID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := EnviarNotificacionGrupo(regla, &contrato.Cliente, []models.Contrato{*contrato}); err != nil {
|
||||
log.Printf("[PAGO-CORREO] error enviando correo de confirmación para contrato %d: %v", contratoID, err)
|
||||
} else {
|
||||
log.Printf("[PAGO-CORREO] correo de confirmación enviado para contrato %d → %s", contratoID, contrato.Cliente.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// ObtenerOCrearEnlacePago devuelve el enlace de pago vigente del contrato.
|
||||
// gateway puede ser "bold", "dlocal" o "ninguna"/"".
|
||||
// Si ya existe un enlace guardado, lo reutiliza (mismo ciclo).
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
<button @click="enviarCorreo(d)" title="Enviar correo" class="text-gray-400 hover:text-purple-500">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</button>
|
||||
<button @click="openHistorial(d)" title="Ver historial" class="text-gray-400 hover:text-indigo-500">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</button>
|
||||
<button @click="openDelete(d)" title="Eliminar" class="text-gray-400 hover:text-red-500">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
|
||||
</button>
|
||||
@@ -174,6 +177,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Historial -->
|
||||
<div x-show="historialModal" x-cloak class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50">
|
||||
<div class="bg-white rounded-t-2xl sm:rounded-xl shadow-2xl w-full sm:max-w-lg mx-0 sm:mx-4 flex flex-col max-h-[85vh]" @click.stop>
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold" x-text="historialTitulo"></h2>
|
||||
<p class="text-xs text-gray-400">Línea de tiempo de actividad</p>
|
||||
</div>
|
||||
<button @click="historialModal=false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-5 flex-1">
|
||||
<!-- Loading -->
|
||||
<div x-show="historialLoading" class="flex justify-center py-8">
|
||||
<svg class="animate-spin w-6 h-6 text-indigo-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
</div>
|
||||
<!-- Timeline -->
|
||||
<div x-show="!historialLoading">
|
||||
<div x-show="historialTimeline.length===0" class="text-center text-gray-400 py-8 text-sm">Sin actividad registrada</div>
|
||||
<ol class="relative border-l border-gray-200 ml-3">
|
||||
<template x-for="(ev, i) in historialTimeline" :key="i">
|
||||
<li class="mb-6 ml-5">
|
||||
<!-- Dot -->
|
||||
<span class="absolute -left-3 flex items-center justify-center w-6 h-6 rounded-full ring-4 ring-white"
|
||||
:class="{
|
||||
'bg-indigo-100': ev.tipo==='creacion',
|
||||
'bg-blue-100': ev.tipo==='renovacion',
|
||||
'bg-purple-100': ev.tipo==='notificacion',
|
||||
'bg-green-100': ev.tipo==='pago' && ev.estado==='ok',
|
||||
'bg-red-100': ev.estado==='error'
|
||||
}">
|
||||
<!-- document -->
|
||||
<template x-if="ev.icono==='document'">
|
||||
<svg class="w-3 h-3 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
</template>
|
||||
<!-- refresh -->
|
||||
<template x-if="ev.icono==='refresh'">
|
||||
<svg class="w-3 h-3 text-blue-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||
</template>
|
||||
<!-- mail -->
|
||||
<template x-if="ev.icono==='mail'">
|
||||
<svg class="w-3 h-3 text-purple-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</template>
|
||||
<!-- currency -->
|
||||
<template x-if="ev.icono==='currency'">
|
||||
<svg class="w-3 h-3 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z"/></svg>
|
||||
</template>
|
||||
</span>
|
||||
<div class="ml-1">
|
||||
<p class="text-sm font-medium text-gray-800" x-text="ev.titulo"></p>
|
||||
<p class="text-xs text-gray-500 mt-0.5" x-text="ev.detalle"></p>
|
||||
<time class="text-xs text-gray-400" x-text="fmtDatetime(ev.fecha)"></time>
|
||||
<span x-show="ev.estado==='error'" class="ml-2 text-xs text-red-500 font-medium">Fallido</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Enviar Notificación -->
|
||||
<div x-show="notifModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4 p-6" @click.stop>
|
||||
@@ -250,6 +316,7 @@ document.addEventListener('alpine:init', () => {
|
||||
addModal: false, editModal: false, deleteModal: false,
|
||||
notifModal: false, notifReglaId: '', notifContratoId: null, notifCliente: '',
|
||||
bienvenidaModal: false, bienvenidaReglaId: '', bienvenidaContratoId: null,
|
||||
historialModal: false, historialTitulo: '', historialTimeline: [], historialLoading: false,
|
||||
clientes: [], servicios: [], reglas: [], reglasBienvenida: [],
|
||||
selectedId: null,
|
||||
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' },
|
||||
@@ -404,6 +471,23 @@ document.addEventListener('alpine:init', () => {
|
||||
return m[tipo] || tipo;
|
||||
},
|
||||
|
||||
async openHistorial(d) {
|
||||
this.historialTitulo = 'Historial — ' + (d.cliente?.nombre || '#'+d.ID);
|
||||
this.historialTimeline = [];
|
||||
this.historialLoading = true;
|
||||
this.historialModal = true;
|
||||
try {
|
||||
const res = await axios.get(`/app/api/contratos/${d.ID}/historial`);
|
||||
this.historialTimeline = res.data.timeline || [];
|
||||
} catch(e) { this.showToast('Error cargando historial', 'error'); }
|
||||
this.historialLoading = false;
|
||||
},
|
||||
|
||||
fmtDatetime(raw) {
|
||||
if (!raw) return '—';
|
||||
return new Date(raw).toLocaleString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||
},
|
||||
|
||||
fmtDate(raw) {
|
||||
if (!raw) return '—';
|
||||
return new Date(raw).toLocaleDateString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric' });
|
||||
|
||||
@@ -118,8 +118,9 @@ func BoldWebhook(c *fiber.Ctx) error {
|
||||
log.Printf("[BOLD] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
|
||||
} else {
|
||||
log.Printf("[BOLD] Webhook: contrato %d marcado como pagado", contratoID)
|
||||
// Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
|
||||
// Notificar SaaS externos + enviar correo de confirmación (goroutine, no bloquea respuesta)
|
||||
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP")
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func SeePlanes(c *fiber.Ctx) error {
|
||||
response, err := services.SeePlanes(*dlocalConfig)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Error al obtener los planes", err),
|
||||
"error": fmt.Sprintf("Error al obtener los planes: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -324,8 +324,9 @@ func DlocalWebhook(c *fiber.Ctx) error {
|
||||
log.Printf("[DLOCAL] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
|
||||
} else {
|
||||
log.Printf("[DLOCAL] Webhook: contrato %d marcado como pagado", contratoID)
|
||||
// Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
|
||||
// Notificar SaaS externos + enviar correo de confirmación (goroutine, no bloquea respuesta)
|
||||
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "dlocal", monto, moneda)
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) {
|
||||
if l.Estado == "PAID" || l.Estado == "AUTHORIZED" {
|
||||
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dlocal_payment_log (id=%d)", contratoID, l.ID)
|
||||
_ = models.MarcarContratoPagado(contratoID)
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
return true, l.CreatedAt.Format("02/01/2006 15:04")
|
||||
}
|
||||
}
|
||||
@@ -133,6 +134,7 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) {
|
||||
} else if paid {
|
||||
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via Bold API (payment_id=%s)", contratoID, paymentID)
|
||||
_ = models.MarcarContratoPagado(contratoID)
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
if paymentID != "" && !models.IsBoldNotificationDuplicate("api-check-"+paymentID) {
|
||||
_ = models.SaveBoldWebhookLog(models.BoldWebhookLog{
|
||||
NotificationID: "api-check-" + paymentID,
|
||||
@@ -156,6 +158,7 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) {
|
||||
} else if paid {
|
||||
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dLocal API (payment_id=%s)", contratoID, paymentID)
|
||||
_ = models.MarcarContratoPagado(contratoID)
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
notifID := "api-check-" + ref
|
||||
if !models.IsDlocalNotificationDuplicate(notifID) {
|
||||
_ = models.SaveDlocalPaymentLog(models.DlocalPaymentLog{
|
||||
|
||||
@@ -228,6 +228,123 @@ func DeleteContrato(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||
}
|
||||
|
||||
// GetHistorialContrato devuelve la línea de tiempo de actividad de un contrato
|
||||
func GetHistorialContrato(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
contrato, err := models.GetContratoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
|
||||
notifLogs, _ := models.GetLogsByContratoID(uint(id))
|
||||
pagoLogs, _ := models.GetDispatchLogsByContratoID(uint(id))
|
||||
|
||||
// Construir timeline unificado
|
||||
type Evento struct {
|
||||
Tipo string `json:"tipo"` // creacion | renovacion | notificacion | pago
|
||||
Icono string `json:"icono"`
|
||||
Titulo string `json:"titulo"`
|
||||
Detalle string `json:"detalle"`
|
||||
Estado string `json:"estado"` // ok | error | info
|
||||
FechaISO string `json:"fecha"`
|
||||
}
|
||||
|
||||
var timeline []Evento
|
||||
|
||||
// Evento: creación del contrato
|
||||
timeline = append(timeline, Evento{
|
||||
Tipo: "creacion",
|
||||
Icono: "document",
|
||||
Titulo: "Contrato creado",
|
||||
Detalle: "Inicio: " + contrato.FechaInicio.Format("02/01/2006") + " · Vence: " + contrato.FechaVencimiento.Format("02/01/2006"),
|
||||
Estado: "info",
|
||||
FechaISO: contrato.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// Evento: renovaciones (updated_at con estado renovado — heurístico por estado)
|
||||
if contrato.Estado == "renovado" {
|
||||
timeline = append(timeline, Evento{
|
||||
Tipo: "renovacion",
|
||||
Icono: "refresh",
|
||||
Titulo: "Contrato renovado",
|
||||
Detalle: "Nuevo vencimiento: " + contrato.FechaVencimiento.Format("02/01/2006"),
|
||||
Estado: "ok",
|
||||
FechaISO: contrato.UpdatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Eventos: notificaciones enviadas
|
||||
for _, n := range notifLogs {
|
||||
estado := "ok"
|
||||
if n.Estado == "fallido" {
|
||||
estado = "error"
|
||||
} else if n.Estado == "pendiente" {
|
||||
estado = "info"
|
||||
}
|
||||
detalle := n.Asunto
|
||||
if n.Estado == "fallido" && n.ErrorMsg != "" {
|
||||
detalle += " · Error: " + n.ErrorMsg
|
||||
}
|
||||
reglaLabel := ""
|
||||
if n.Regla.ID > 0 {
|
||||
reglaLabel = " (" + n.Regla.Nombre + ")"
|
||||
}
|
||||
timeline = append(timeline, Evento{
|
||||
Tipo: "notificacion",
|
||||
Icono: "mail",
|
||||
Titulo: "Notificación enviada" + reglaLabel,
|
||||
Detalle: detalle,
|
||||
Estado: estado,
|
||||
FechaISO: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Eventos: pagos / dispatches
|
||||
for _, p := range pagoLogs {
|
||||
estado := "ok"
|
||||
if p.Estado == "failed" {
|
||||
estado = "error"
|
||||
}
|
||||
detalle := p.Referencia
|
||||
if p.PayerEmail != "" {
|
||||
detalle += " · " + p.PayerEmail
|
||||
}
|
||||
if p.Fuente != "" {
|
||||
detalle += " · " + p.Fuente
|
||||
}
|
||||
timeline = append(timeline, Evento{
|
||||
Tipo: "pago",
|
||||
Icono: "currency",
|
||||
Titulo: "Pago recibido",
|
||||
Detalle: detalle,
|
||||
Estado: estado,
|
||||
FechaISO: p.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Ordenar por fecha desc
|
||||
for i := 0; i < len(timeline)-1; i++ {
|
||||
for j := i + 1; j < len(timeline); j++ {
|
||||
if timeline[j].FechaISO > timeline[i].FechaISO {
|
||||
timeline[i], timeline[j] = timeline[j], timeline[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"contrato": fiber.Map{
|
||||
"id": contrato.ID,
|
||||
"cliente": contrato.Cliente.Nombre,
|
||||
"estado": contrato.Estado,
|
||||
},
|
||||
"timeline": timeline,
|
||||
"total": len(timeline),
|
||||
})
|
||||
}
|
||||
|
||||
func calcularFechaVencimiento(desde time.Time, periodicidad string) time.Time {
|
||||
switch periodicidad {
|
||||
case "mensual":
|
||||
|
||||
@@ -33,6 +33,7 @@ func RenovacionesRoutes(protected fiber.Router) {
|
||||
protected.Delete("/api/contratos/:id", controllers.DeleteContrato)
|
||||
protected.Post("/api/contratos/:id/renovar", controllers.RenovarContrato)
|
||||
protected.Post("/api/contratos/:id/enviar-correo", controllers.EnviarCorreoContrato)
|
||||
protected.Get("/api/contratos/:id/historial", controllers.GetHistorialContrato)
|
||||
|
||||
// ─── Plantillas de correo ─────────────────────────────────────────
|
||||
protected.Get("/plantillas-correo", middlewares.MenuMiddleware, controllers.PlantillasView)
|
||||
|
||||
Reference in New Issue
Block a user