up
This commit is contained in:
@@ -0,0 +1,681 @@
|
|||||||
|
# Plan de Trabajo — Módulo de Renovaciones y Contratos
|
||||||
|
|
||||||
|
> Sistema completo de gestión de servicios, clientes, contratos y notificaciones automáticas de vencimiento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Índice
|
||||||
|
|
||||||
|
1. [Visión General](#1-visión-general)
|
||||||
|
2. [Arquitectura de Datos](#2-arquitectura-de-datos)
|
||||||
|
3. [Módulos del Sistema](#3-módulos-del-sistema)
|
||||||
|
4. [Flujo de Trabajo](#4-flujo-de-trabajo)
|
||||||
|
5. [Sistema de Notificaciones](#5-sistema-de-notificaciones)
|
||||||
|
6. [Plantillas de Correo](#6-plantillas-de-correo)
|
||||||
|
7. [Configuración SMTP](#7-configuración-smtp)
|
||||||
|
8. [Plan de Implementación por Fases](#8-plan-de-implementación-por-fases)
|
||||||
|
9. [Estructura de Archivos](#9-estructura-de-archivos)
|
||||||
|
10. [API Endpoints](#10-api-endpoints)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visión General
|
||||||
|
|
||||||
|
El módulo de **Renovaciones** permite a la empresa gestionar el ciclo de vida completo de sus servicios vendidos a clientes: creación de catálogo, asignación a clientes con fechas de vencimiento, envío automático y manual de correos de cobro/renovación, y configuración total de notificaciones programadas.
|
||||||
|
|
||||||
|
### Capacidades principales
|
||||||
|
|
||||||
|
| Capacidad | Descripción |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Catálogo de servicios | Crear servicios con precio, tipo (renovable/único), periodicidad |
|
||||||
|
| Gestión de clientes | CRUD completo de clientes con datos de contacto |
|
||||||
|
| Contratos / Asignaciones | Relacionar clientes con servicios, definir fechas de inicio y vencimiento |
|
||||||
|
| Agrupación de correos | Si varios servicios vencen la misma fecha → un solo correo con el total |
|
||||||
|
| Notificaciones programadas | Configurar N días antes del vencimiento, con cron interno |
|
||||||
|
| Editor de plantillas | Editor visual con preview en tiempo real |
|
||||||
|
| Configuración SMTP | Panel para cambiar servidor de correo sin reiniciar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitectura de Datos
|
||||||
|
|
||||||
|
### Modelo Entidad-Relación
|
||||||
|
|
||||||
|
```
|
||||||
|
clientes ─────────── contratos ─────────── servicios
|
||||||
|
│ │ │
|
||||||
|
│ ├── fecha_inicio ├── tipo: renovable / unico
|
||||||
|
│ ├── fecha_vencimiento ├── precio
|
||||||
|
│ ├── estado ├── periodicidad (mensual/anual/etc)
|
||||||
|
│ └── notas └── descripcion
|
||||||
|
│
|
||||||
|
└── email, telefono, empresa, ...
|
||||||
|
|
||||||
|
contratos ──── notificaciones_log
|
||||||
|
├── fecha_envio
|
||||||
|
├── tipo (aviso / vencimiento / recordatorio)
|
||||||
|
└── estado (enviado / fallido / pendiente)
|
||||||
|
|
||||||
|
smtp_config (tabla singleton)
|
||||||
|
plantillas_correo
|
||||||
|
notificacion_reglas (días antes, activo, plantilla_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tablas SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Servicios / Productos
|
||||||
|
CREATE TABLE servicios (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
nombre TEXT NOT NULL,
|
||||||
|
descripcion TEXT,
|
||||||
|
precio NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||||
|
moneda TEXT NOT NULL DEFAULT 'COP',
|
||||||
|
tipo TEXT NOT NULL DEFAULT 'renovable', -- 'renovable' | 'unico'
|
||||||
|
periodicidad TEXT, -- 'mensual' | 'trimestral' | 'semestral' | 'anual' | NULL
|
||||||
|
activo BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Clientes
|
||||||
|
CREATE TABLE clientes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
nombre TEXT NOT NULL,
|
||||||
|
empresa TEXT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
email_cc TEXT, -- correos adicionales separados por coma
|
||||||
|
telefono TEXT,
|
||||||
|
documento TEXT,
|
||||||
|
notas TEXT,
|
||||||
|
activo BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Contratos / Asignaciones
|
||||||
|
CREATE TABLE contratos (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
cliente_id BIGINT NOT NULL REFERENCES clientes(id),
|
||||||
|
servicio_id BIGINT NOT NULL REFERENCES servicios(id),
|
||||||
|
fecha_inicio DATE NOT NULL,
|
||||||
|
fecha_vencimiento DATE NOT NULL,
|
||||||
|
precio_acordado NUMERIC(12,2), -- puede diferir del precio base
|
||||||
|
estado TEXT NOT NULL DEFAULT 'activo', -- 'activo' | 'vencido' | 'cancelado' | 'renovado'
|
||||||
|
auto_renovar BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
notas TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reglas de notificación (configurable por días antes del vencimiento)
|
||||||
|
CREATE TABLE notificacion_reglas (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
nombre TEXT NOT NULL, -- ej: "Aviso 30 días antes"
|
||||||
|
dias_antes INT NOT NULL, -- ej: 30, 15, 7, 1
|
||||||
|
plantilla_id BIGINT REFERENCES plantillas_correo(id),
|
||||||
|
activo BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
aplica_a TEXT NOT NULL DEFAULT 'todos' -- 'todos' | 'renovable' | 'unico'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Plantillas de correo
|
||||||
|
CREATE TABLE plantillas_correo (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
nombre TEXT NOT NULL,
|
||||||
|
asunto TEXT NOT NULL,
|
||||||
|
cuerpo_html TEXT NOT NULL, -- soporta variables: {{.ClienteNombre}}, {{.Servicios}}, {{.Total}}, etc.
|
||||||
|
tipo TEXT NOT NULL DEFAULT 'renovacion' -- 'renovacion' | 'vencimiento' | 'pago' | 'personalizado'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Log de notificaciones enviadas
|
||||||
|
CREATE TABLE notificaciones_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
cliente_id BIGINT REFERENCES clientes(id),
|
||||||
|
regla_id BIGINT REFERENCES notificacion_reglas(id),
|
||||||
|
contratos_ids TEXT, -- JSON array de IDs agrupados
|
||||||
|
fecha_envio TIMESTAMPTZ,
|
||||||
|
estado TEXT NOT NULL DEFAULT 'pendiente', -- 'enviado' | 'fallido' | 'pendiente'
|
||||||
|
error_msg TEXT,
|
||||||
|
asunto TEXT,
|
||||||
|
preview_html TEXT -- copia del correo enviado
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Configuración SMTP (singleton, solo 1 fila activa)
|
||||||
|
CREATE TABLE smtp_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INT NOT NULL DEFAULT 587,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL, -- almacenado cifrado (AES-256)
|
||||||
|
encryption TEXT NOT NULL DEFAULT 'tls', -- 'tls' | 'ssl' | 'none'
|
||||||
|
from_address TEXT NOT NULL,
|
||||||
|
from_name TEXT NOT NULL,
|
||||||
|
activo BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Módulos del Sistema
|
||||||
|
|
||||||
|
### 3.1 Catálogo de Servicios (`/app/servicios`)
|
||||||
|
|
||||||
|
**Funcionalidades:**
|
||||||
|
- Listar servicios con filtro por tipo y estado
|
||||||
|
- Crear/editar/eliminar servicio
|
||||||
|
- Campos: nombre, descripción, precio, moneda, tipo (renovable | único), periodicidad
|
||||||
|
- Badge visual diferenciando renovables de únicos
|
||||||
|
- Indicador de cuántos contratos activos tiene cada servicio
|
||||||
|
|
||||||
|
**Campos del formulario:**
|
||||||
|
|
||||||
|
| Campo | Tipo | Requerido | Notas |
|
||||||
|
|-------|------|-----------|-------|
|
||||||
|
| Nombre | texto | ✅ | |
|
||||||
|
| Descripción | textarea | ❌ | |
|
||||||
|
| Precio | decimal | ✅ | |
|
||||||
|
| Moneda | select | ✅ | COP / USD / EUR |
|
||||||
|
| Tipo | radio | ✅ | Renovable / Único |
|
||||||
|
| Periodicidad | select | si renovable | Mensual / Trimestral / Semestral / Anual |
|
||||||
|
| Activo | toggle | ✅ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Clientes (`/app/clientes`)
|
||||||
|
|
||||||
|
**Funcionalidades:**
|
||||||
|
- CRUD completo de clientes
|
||||||
|
- Vista detalle del cliente con todos sus contratos activos/vencidos
|
||||||
|
- Historial de correos enviados al cliente
|
||||||
|
- Múltiples emails CC por cliente
|
||||||
|
|
||||||
|
**Campos del formulario:**
|
||||||
|
|
||||||
|
| Campo | Tipo | Requerido |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| Nombre completo | texto | ✅ |
|
||||||
|
| Empresa / Razón social | texto | ❌ |
|
||||||
|
| Email principal | email | ✅ |
|
||||||
|
| Emails CC | texto (separados por coma) | ❌ |
|
||||||
|
| Teléfono / WhatsApp | texto | ❌ |
|
||||||
|
| Documento (NIT/CC) | texto | ❌ |
|
||||||
|
| Notas internas | textarea | ❌ |
|
||||||
|
| Activo | toggle | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Contratos / Asignaciones (`/app/contratos`)
|
||||||
|
|
||||||
|
**Vista principal:**
|
||||||
|
- Tabla con columnas: Cliente, Servicio, Fecha inicio, Fecha vencimiento, Estado, Días restantes
|
||||||
|
- Filtros: por cliente, por servicio, por estado, por rango de fechas
|
||||||
|
- Indicador visual de urgencia (verde > 30 días, amarillo 1-30 días, rojo vencido)
|
||||||
|
- Botón "Renovar" que crea un nuevo contrato a partir del vencido
|
||||||
|
|
||||||
|
**Formulario de asignación:**
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
|-------|------|-------|
|
||||||
|
| Cliente | select buscable | autocompletado |
|
||||||
|
| Servicio | select buscable | muestra precio base |
|
||||||
|
| Fecha inicio | date | default: hoy |
|
||||||
|
| Fecha vencimiento | date | calculada automáticamente según periodicidad |
|
||||||
|
| Precio acordado | decimal | editable, pre-rellena con precio del servicio |
|
||||||
|
| Auto-renovar | toggle | crea nuevo contrato automáticamente al vencer |
|
||||||
|
| Notas | textarea | |
|
||||||
|
|
||||||
|
**Vista detalle del contrato:**
|
||||||
|
- Historial de renovaciones anteriores
|
||||||
|
- Timeline de notificaciones enviadas
|
||||||
|
- Botón "Enviar correo manual"
|
||||||
|
- Botón "Renovar ahora"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Reglas de Notificación (`/app/notificaciones/reglas`)
|
||||||
|
|
||||||
|
Permite configurar cuándo y qué correo enviar antes del vencimiento.
|
||||||
|
|
||||||
|
**Ejemplos de reglas:**
|
||||||
|
|
||||||
|
| Regla | Días antes | Plantilla | Aplica a |
|
||||||
|
|-------|-----------|-----------|----------|
|
||||||
|
| Aviso temprano | 30 | Plantilla "Renovación próxima" | Renovables |
|
||||||
|
| Recordatorio | 7 | Plantilla "Vence en 7 días" | Todos |
|
||||||
|
| Último aviso | 1 | Plantilla "Vence mañana" | Todos |
|
||||||
|
| Vencido | 0 | Plantilla "Servicio vencido" | Todos |
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
|-------|------|-------|
|
||||||
|
| Nombre de la regla | texto | |
|
||||||
|
| Días antes del vencimiento | número | 0 = día del vencimiento |
|
||||||
|
| Plantilla de correo | select | |
|
||||||
|
| Aplica a | select | Todos / Solo renovables / Solo únicos |
|
||||||
|
| Activo | toggle | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 Plantillas de Correo (`/app/notificaciones/plantillas`)
|
||||||
|
|
||||||
|
**Editor visual con:**
|
||||||
|
- Editor de HTML (textarea con resaltado)
|
||||||
|
- Panel de variables disponibles (click to insert)
|
||||||
|
- Preview en tiempo real lado a lado
|
||||||
|
- Envío de correo de prueba a email específico
|
||||||
|
|
||||||
|
**Variables disponibles en plantillas:**
|
||||||
|
|
||||||
|
```
|
||||||
|
{{.ClienteNombre}} → Nombre del cliente
|
||||||
|
{{.ClienteEmpresa}} → Empresa del cliente
|
||||||
|
{{.Servicios}} → Tabla HTML de servicios (nombre, vencimiento, precio)
|
||||||
|
{{.Total}} → Total en moneda local
|
||||||
|
{{.FechaVencimiento}} → Fecha de vencimiento (la más próxima si hay varias)
|
||||||
|
{{.DiasRestantes}} → Días que faltan para vencer
|
||||||
|
{{.LinkPago}} → URL de pago (configurable)
|
||||||
|
{{.EmpresaNombre}} → Nombre de la empresa (desde config)
|
||||||
|
{{.FechaActual}} → Fecha de hoy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tipo de plantillas:**
|
||||||
|
|
||||||
|
| Tipo | Uso |
|
||||||
|
|------|-----|
|
||||||
|
| `renovacion` | Aviso de renovación próxima |
|
||||||
|
| `vencimiento` | El servicio está a punto de vencer / vencido |
|
||||||
|
| `pago` | Confirmación o solicitud de pago |
|
||||||
|
| `personalizado` | Uso libre / envío manual |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 Historial de Envíos (`/app/notificaciones/historial`)
|
||||||
|
|
||||||
|
- Tabla con todos los correos enviados
|
||||||
|
- Filtro por cliente, fecha, estado (enviado/fallido)
|
||||||
|
- Ver HTML del correo enviado (modal preview)
|
||||||
|
- Reenviar correo fallido con un clic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 Configuración SMTP (`/app/configuracion/smtp`)
|
||||||
|
|
||||||
|
Panel para configurar el servidor de correo saliente sin tocar archivos.
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
|
||||||
|
| Campo | Tipo | Default |
|
||||||
|
|-------|------|---------|
|
||||||
|
| Host SMTP | texto | smtp.gmail.com |
|
||||||
|
| Puerto | número | 587 |
|
||||||
|
| Usuario | email | |
|
||||||
|
| Contraseña | password (cifrada) | |
|
||||||
|
| Cifrado | select | TLS / SSL / Ninguno |
|
||||||
|
| Nombre remitente | texto | U-site |
|
||||||
|
| Email remitente | email | |
|
||||||
|
|
||||||
|
- Botón **"Probar conexión"** — envía correo de prueba y muestra resultado
|
||||||
|
- La config se guarda en BD (cifrada) y sobrescribe la del `config.yml` en runtime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flujo de Trabajo
|
||||||
|
|
||||||
|
### Flujo de creación de un contrato
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Crear servicio en catálogo
|
||||||
|
↓
|
||||||
|
2. Crear/seleccionar cliente
|
||||||
|
↓
|
||||||
|
3. Crear contrato (asignar servicio + fechas + precio)
|
||||||
|
↓
|
||||||
|
4. Sistema calcula automáticamente
|
||||||
|
cuándo disparar notificaciones
|
||||||
|
según reglas configuradas
|
||||||
|
↓
|
||||||
|
5. Cron diario evalúa contratos
|
||||||
|
y dispara correos agrupados
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lógica de agrupación de correos
|
||||||
|
|
||||||
|
```
|
||||||
|
Para cada cliente con contratos próximos a vencer:
|
||||||
|
│
|
||||||
|
├── mismo_dia = contratos donde fecha_vencimiento == misma fecha
|
||||||
|
│ └── → UN solo correo con tabla de servicios + total sumado
|
||||||
|
│
|
||||||
|
└── fechas_distintas = contratos con fechas diferentes
|
||||||
|
└── → UN correo por cada fecha de vencimiento distinta
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo práctico:**
|
||||||
|
- Cliente "Empresa ABC" tiene:
|
||||||
|
- Dominio → vence 15 mayo → correo independiente
|
||||||
|
- Hosting + SSL → ambos vencen 15 mayo → **un solo correo con total combinado**
|
||||||
|
- Mantenimiento → vence 30 mayo → correo independiente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sistema de Notificaciones
|
||||||
|
|
||||||
|
### Arquitectura del Cron
|
||||||
|
|
||||||
|
El sistema usa un **cron interno en Go** (librería `robfig/cron`) que se inicia al arrancar la aplicación.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Se ejecuta diariamente a las 8:00 AM
|
||||||
|
cron.AddFunc("0 8 * * *", jobs.ProcesarVencimientos)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job: `ProcesarVencimientos`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Obtener todas las reglas activas (ordenadas por dias_antes)
|
||||||
|
2. Para cada regla:
|
||||||
|
a. Buscar contratos donde:
|
||||||
|
- estado = 'activo'
|
||||||
|
- fecha_vencimiento = HOY + dias_antes
|
||||||
|
- NO exista ya un envío del mismo tipo en notificaciones_log
|
||||||
|
b. Agrupar contratos encontrados por cliente + fecha_vencimiento
|
||||||
|
c. Para cada grupo:
|
||||||
|
- Renderizar plantilla con datos del grupo
|
||||||
|
- Enviar correo (con CC si aplica)
|
||||||
|
- Registrar en notificaciones_log
|
||||||
|
3. Si auto_renovar = true y estado = vencido:
|
||||||
|
- Crear nuevo contrato con nueva fecha de vencimiento
|
||||||
|
- Actualizar estado del anterior a 'renovado'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estados de un contrato
|
||||||
|
|
||||||
|
```
|
||||||
|
activo ──────────── (cron notifica) ──────────── vence
|
||||||
|
│ │
|
||||||
|
├── renovado (si auto_renovar o manual) │
|
||||||
|
│ │
|
||||||
|
└── cancelado (manual) vencido
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Plantillas de Correo
|
||||||
|
|
||||||
|
### Variables en el cuerpo HTML
|
||||||
|
|
||||||
|
El motor de plantillas usa `html/template` de Go (ya usado en el proyecto).
|
||||||
|
|
||||||
|
### Plantilla base sugerida — Renovación
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family:Inter,sans-serif; background:#f8fafc; padding:20px">
|
||||||
|
<div style="max-width:600px; margin:0 auto; background:white; border-radius:12px; overflow:hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background:#1e293b; padding:24px; text-align:center">
|
||||||
|
<h1 style="color:#8eb02f; margin:0">U-site</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Body -->
|
||||||
|
<div style="padding:32px">
|
||||||
|
<p>Hola <strong>{{.ClienteNombre}}</strong>,</p>
|
||||||
|
<p>Te recordamos que los siguientes servicios vencen próximamente:</p>
|
||||||
|
|
||||||
|
<!-- Tabla de servicios -->
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:20px 0">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f1f5f9">
|
||||||
|
<th style="padding:10px; text-align:left">Servicio</th>
|
||||||
|
<th style="padding:10px; text-align:right">Vencimiento</th>
|
||||||
|
<th style="padding:10px; text-align:right">Valor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Servicios}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px; border-bottom:1px solid #e2e8f0">{{.Nombre}}</td>
|
||||||
|
<td style="padding:10px; border-bottom:1px solid #e2e8f0; text-align:right">{{.FechaVencimiento}}</td>
|
||||||
|
<td style="padding:10px; border-bottom:1px solid #e2e8f0; text-align:right">{{.Precio}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="padding:10px; font-weight:bold">Total</td>
|
||||||
|
<td style="padding:10px; font-weight:bold; text-align:right; color:#8eb02f">{{.Total}}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin:30px 0">
|
||||||
|
<a href="{{.LinkPago}}" style="background:#8eb02f; color:white; padding:14px 28px; border-radius:8px; text-decoration:none; font-weight:bold">
|
||||||
|
Renovar ahora →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background:#f8fafc; padding:16px; text-align:center; font-size:12px; color:#64748b">
|
||||||
|
© 2026 U-site — {{.EmpresaNombre}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview en tiempo real
|
||||||
|
|
||||||
|
- Frontend: Alpine.js + `<iframe srcdoc="...">` actualizado en cada keystroke con debounce de 500ms
|
||||||
|
- El preview sustituye las variables por datos de ejemplo configurables
|
||||||
|
- Botón "Enviar prueba" → POST `/app/notificaciones/plantillas/:id/test` con email destino
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Configuración SMTP
|
||||||
|
|
||||||
|
### Almacenamiento seguro
|
||||||
|
|
||||||
|
La contraseña SMTP se cifra con AES-256-GCM antes de guardar en BD, usando el `app_jwt_secret` como clave de cifrado (ya disponible en `config.yml`).
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Guardar: cifrar contraseña antes de INSERT
|
||||||
|
encrypted := utils.Encrypt(password, app.Http.Token.AppJwtSecret)
|
||||||
|
|
||||||
|
// Usar: descifrar antes de conectar
|
||||||
|
plain := utils.Decrypt(encrypted, app.Http.Token.AppJwtSecret)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Override en runtime
|
||||||
|
|
||||||
|
Al guardar una nueva configuración SMTP el sistema:
|
||||||
|
1. Descifra la contraseña
|
||||||
|
2. Actualiza `app.Http.Mail.*` en memoria
|
||||||
|
3. **No requiere reiniciar** el servidor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Plan de Implementación por Fases
|
||||||
|
|
||||||
|
### Fase 1 — Modelos y migración de BD *(~1-2 días)*
|
||||||
|
- [ ] Crear modelos Go: `Servicio`, `Cliente`, `Contrato`, `NotificacionRegla`, `PlantillaCorreo`, `NotificacionLog`, `SmtpConfig`
|
||||||
|
- [ ] Agregar al `Migrate()` todas las nuevas tablas
|
||||||
|
- [ ] Seed inicial: 1 plantilla por defecto, reglas básicas (30, 7, 1 día)
|
||||||
|
|
||||||
|
### Fase 2 — CRUD Servicios y Clientes *(~2 días)*
|
||||||
|
- [ ] Controller + rutas para Servicios
|
||||||
|
- [ ] Controller + rutas para Clientes
|
||||||
|
- [ ] Vistas HTML: lista, formulario (modal), detalle
|
||||||
|
- [ ] Búsqueda y paginación
|
||||||
|
|
||||||
|
### Fase 3 — Contratos / Asignaciones *(~2-3 días)*
|
||||||
|
- [ ] Controller + rutas para Contratos
|
||||||
|
- [ ] Select2 para cliente y servicio con autocompletado AJAX
|
||||||
|
- [ ] Cálculo automático de fecha de vencimiento según periodicidad
|
||||||
|
- [ ] Vista principal con indicadores de color por días restantes
|
||||||
|
- [ ] Botón renovar (clona contrato con nueva fecha)
|
||||||
|
- [ ] Historial de renovaciones
|
||||||
|
|
||||||
|
### Fase 4 — Plantillas de correo con preview *(~2 días)*
|
||||||
|
- [ ] CRUD de plantillas
|
||||||
|
- [ ] Editor HTML con preview en `<iframe srcdoc>`
|
||||||
|
- [ ] API `/plantillas/:id/preview` → renderiza con datos de ejemplo
|
||||||
|
- [ ] Endpoint `/plantillas/:id/test` → envía correo de prueba
|
||||||
|
|
||||||
|
### Fase 5 — Reglas de notificación y Cron *(~2 días)*
|
||||||
|
- [ ] CRUD de reglas
|
||||||
|
- [ ] Job `ProcesarVencimientos` con lógica de agrupación
|
||||||
|
- [ ] Inicializar cron al arrancar (`app.go`)
|
||||||
|
- [ ] Evitar doble envío (check en `notificaciones_log`)
|
||||||
|
|
||||||
|
### Fase 6 — Historial y reenvío *(~1 día)*
|
||||||
|
- [ ] Vista historial de envíos con filtros
|
||||||
|
- [ ] Modal preview del correo enviado
|
||||||
|
- [ ] Botón reenviar
|
||||||
|
|
||||||
|
### Fase 7 — Configuración SMTP *(~1 día)*
|
||||||
|
- [ ] Panel CRUD de configuración SMTP
|
||||||
|
- [ ] Cifrado AES de contraseña
|
||||||
|
- [ ] Override en runtime de `app.Http.Mail`
|
||||||
|
- [ ] Botón "Probar conexión"
|
||||||
|
|
||||||
|
### Fase 8 — Integración al menú y permisos *(~0.5 días)*
|
||||||
|
- [ ] Agregar módulo "Renovaciones" al sidebar
|
||||||
|
- [ ] Asignar permisos RBAC (Casbin)
|
||||||
|
- [ ] Registrar módulo y submodules en BD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/
|
||||||
|
models/
|
||||||
|
servicio.go
|
||||||
|
cliente.go
|
||||||
|
contrato.go
|
||||||
|
notificacion_regla.go
|
||||||
|
plantilla_correo.go
|
||||||
|
notificacion_log.go
|
||||||
|
smtp_config.go
|
||||||
|
|
||||||
|
rest/
|
||||||
|
controllers/
|
||||||
|
servicio_controller.go
|
||||||
|
cliente_controller.go
|
||||||
|
contrato_controller.go
|
||||||
|
notificacion_controller.go
|
||||||
|
plantilla_controller.go
|
||||||
|
smtp_config_controller.go
|
||||||
|
routes/
|
||||||
|
renovaciones.go ← agrupa todas las rutas del módulo
|
||||||
|
|
||||||
|
pkg/
|
||||||
|
services/
|
||||||
|
renovacion_service.go ← lógica de agrupación y envío
|
||||||
|
cron_service.go ← job diario de vencimientos
|
||||||
|
|
||||||
|
resources/views/
|
||||||
|
servicios/
|
||||||
|
index.html
|
||||||
|
form.html
|
||||||
|
clientes/
|
||||||
|
index.html
|
||||||
|
form.html
|
||||||
|
detalle.html
|
||||||
|
contratos/
|
||||||
|
index.html
|
||||||
|
form.html
|
||||||
|
detalle.html
|
||||||
|
notificaciones/
|
||||||
|
reglas.html
|
||||||
|
plantillas.html
|
||||||
|
historial.html
|
||||||
|
editor.html ← editor con preview
|
||||||
|
configuracion/
|
||||||
|
smtp.html
|
||||||
|
emails/
|
||||||
|
renovacion.html ← plantilla base de correo
|
||||||
|
vencimiento.html
|
||||||
|
pago.html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. API Endpoints
|
||||||
|
|
||||||
|
### Servicios
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/app/servicios` | Lista de servicios |
|
||||||
|
| GET | `/app/loadservicios` | JSON para DataTable |
|
||||||
|
| POST | `/app/servicios` | Crear servicio |
|
||||||
|
| PUT | `/app/servicios/:id` | Actualizar |
|
||||||
|
| DELETE | `/app/servicios/:id` | Eliminar |
|
||||||
|
|
||||||
|
### Clientes
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/app/clientes` | Lista de clientes |
|
||||||
|
| GET | `/app/loadclientes` | JSON para DataTable / Select2 |
|
||||||
|
| GET | `/app/clientes/:id` | Detalle con contratos |
|
||||||
|
| POST | `/app/clientes` | Crear |
|
||||||
|
| PUT | `/app/clientes/:id` | Actualizar |
|
||||||
|
| DELETE | `/app/clientes/:id` | Eliminar |
|
||||||
|
|
||||||
|
### Contratos
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/app/contratos` | Lista con filtros |
|
||||||
|
| GET | `/app/loadcontratos` | JSON para DataTable |
|
||||||
|
| POST | `/app/contratos` | Crear asignación |
|
||||||
|
| PUT | `/app/contratos/:id` | Actualizar |
|
||||||
|
| POST | `/app/contratos/:id/renovar` | Renovar contrato |
|
||||||
|
| POST | `/app/contratos/:id/correo` | Envío manual de correo |
|
||||||
|
| DELETE | `/app/contratos/:id` | Cancelar |
|
||||||
|
|
||||||
|
### Notificaciones / Plantillas
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/app/notificaciones/reglas` | Lista de reglas |
|
||||||
|
| POST | `/app/notificaciones/reglas` | Crear regla |
|
||||||
|
| PUT | `/app/notificaciones/reglas/:id` | Actualizar |
|
||||||
|
| DELETE | `/app/notificaciones/reglas/:id` | Eliminar |
|
||||||
|
| GET | `/app/notificaciones/plantillas` | Lista de plantillas |
|
||||||
|
| POST | `/app/notificaciones/plantillas` | Crear |
|
||||||
|
| PUT | `/app/notificaciones/plantillas/:id` | Actualizar |
|
||||||
|
| POST | `/app/notificaciones/plantillas/:id/preview` | Renderiza con datos de ejemplo |
|
||||||
|
| POST | `/app/notificaciones/plantillas/:id/test` | Enviar correo de prueba |
|
||||||
|
| GET | `/app/notificaciones/historial` | Log de envíos |
|
||||||
|
| POST | `/app/notificaciones/historial/:id/reenviar` | Reenviar |
|
||||||
|
|
||||||
|
### SMTP
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/app/configuracion/smtp` | Ver configuración actual |
|
||||||
|
| POST | `/app/configuracion/smtp` | Guardar configuración |
|
||||||
|
| POST | `/app/configuracion/smtp/test` | Probar conexión |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas Técnicas
|
||||||
|
|
||||||
|
- **Cron:** usar `github.com/robfig/cron/v3` (agregar a `go.mod`)
|
||||||
|
- **Preview correo:** `<iframe srcdoc>` + Alpine `x-data` con debounce — sin backend para el preview inicial, JS reemplaza variables con datos de ejemplo en el cliente
|
||||||
|
- **Agrupación de correos:** lógica en `renovacion_service.go`, agrupa por `cliente_id + fecha_vencimiento` antes de enviar
|
||||||
|
- **Doble envío:** antes de enviar cada grupo, consultar `notificaciones_log` filtrando `DATE(created_at) = TODAY AND cliente_id = X AND regla_id = Y AND contratos_ids CONTAINS Z`
|
||||||
|
- **Moneda:** formatear con `fmt.Sprintf("$ %,.2f", valor)` o librería `golang.org/x/text`
|
||||||
|
- **Auto-renovar:** el job crea el nuevo contrato con `fecha_inicio = fecha_vencimiento_anterior + 1 día` y la nueva `fecha_vencimiento` calculada según periodicidad del servicio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento generado el 29 de abril de 2026 — U-site Admin System*
|
||||||
@@ -111,6 +111,7 @@ require (
|
|||||||
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
|
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||||
github.com/stretchr/testify v1.10.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
|
|||||||
@@ -587,6 +587,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
|
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/migrations"
|
"github.com/sujit-baniya/fiber-boilerplate/migrations"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
|
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +42,9 @@ func main() {
|
|||||||
// Ejecutar migraciones
|
// Ejecutar migraciones
|
||||||
migrations.Migrate()
|
migrations.Migrate()
|
||||||
} else {
|
} else {
|
||||||
|
// Iniciar cron de vencimientos
|
||||||
|
services.IniciarCron()
|
||||||
|
defer services.DetenerCron()
|
||||||
// Cargar rutas
|
// Cargar rutas
|
||||||
routes.LoadRoutes(app.Http.Server.App)
|
routes.LoadRoutes(app.Http.Server.App)
|
||||||
app.Http.Route404()
|
app.Http.Route404()
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ func Migrate() {
|
|||||||
&models.OssApi{},
|
&models.OssApi{},
|
||||||
&models.VcfVcard{},
|
&models.VcfVcard{},
|
||||||
&models.DlocalApi{},
|
&models.DlocalApi{},
|
||||||
|
// Módulo de Renovaciones
|
||||||
|
&models.Servicio{},
|
||||||
|
&models.Cliente{},
|
||||||
|
&models.PlantillaCorreo{},
|
||||||
|
&models.NotificacionRegla{},
|
||||||
|
&models.Contrato{},
|
||||||
|
&models.NotificacionLog{},
|
||||||
|
&models.SmtpConfig{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("Error during main migration: %v", err)
|
log.Fatalf("Error during main migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cliente struct {
|
||||||
|
gorm.Model
|
||||||
|
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||||
|
Empresa string `json:"empresa" gorm:"column:empresa"`
|
||||||
|
Email string `json:"email" gorm:"column:email"`
|
||||||
|
EmailCC string `json:"email_cc" gorm:"column:email_cc"`
|
||||||
|
Telefono string `json:"telefono" gorm:"column:telefono"`
|
||||||
|
Documento string `json:"documento" gorm:"column:documento"`
|
||||||
|
Notas string `json:"notas" gorm:"column:notas"`
|
||||||
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Cliente) TableName() string { return "clientes" }
|
||||||
|
|
||||||
|
func GetAllClientes(limit, offset int, search string) ([]Cliente, int64, error) {
|
||||||
|
var items []Cliente
|
||||||
|
var total int64
|
||||||
|
db := app.Http.Database.DB.Model(&Cliente{})
|
||||||
|
if search != "" {
|
||||||
|
db = db.Where("nombre ILIKE ? OR email ILIKE ? OR empresa ILIKE ?",
|
||||||
|
"%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllClientesSelect() ([]Cliente, error) {
|
||||||
|
var items []Cliente
|
||||||
|
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClienteByID(id uint) (*Cliente, error) {
|
||||||
|
var item Cliente
|
||||||
|
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCliente(c Cliente) error {
|
||||||
|
return app.Http.Database.DB.Create(&c).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCliente(c Cliente) error {
|
||||||
|
return app.Http.Database.DB.Model(&c).Updates(c).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCliente(id uint) error {
|
||||||
|
var c Cliente
|
||||||
|
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.Http.Database.DB.Delete(&c).Error; err != nil {
|
||||||
|
log.Printf("Error deleting cliente: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Contrato struct {
|
||||||
|
gorm.Model
|
||||||
|
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id"`
|
||||||
|
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||||
|
ServicioID uint `json:"servicio_id" gorm:"column:servicio_id"`
|
||||||
|
Servicio Servicio `json:"servicio" gorm:"foreignKey:ServicioID"`
|
||||||
|
FechaInicio time.Time `json:"fecha_inicio" gorm:"column:fecha_inicio"`
|
||||||
|
FechaVencimiento time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
|
||||||
|
PrecioAcordado float64 `json:"precio_acordado" gorm:"column:precio_acordado"`
|
||||||
|
Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo | vencido | cancelado | renovado
|
||||||
|
AutoRenovar bool `json:"auto_renovar" gorm:"column:auto_renovar;default:false"`
|
||||||
|
Notas string `json:"notas" gorm:"column:notas"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Contrato) TableName() string { return "contratos" }
|
||||||
|
|
||||||
|
func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int64, error) {
|
||||||
|
var items []Contrato
|
||||||
|
var total int64
|
||||||
|
db := app.Http.Database.DB.Model(&Contrato{}).
|
||||||
|
Preload("Cliente").Preload("Servicio")
|
||||||
|
if search != "" {
|
||||||
|
db = db.Joins("JOIN clientes ON clientes.id = contratos.cliente_id").
|
||||||
|
Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?",
|
||||||
|
"%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
if estado != "" {
|
||||||
|
db = db.Where("contratos.estado = ?", estado)
|
||||||
|
}
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := db.Order("contratos.fecha_vencimiento ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetContratoByID(id uint) (*Contrato, error) {
|
||||||
|
var item Contrato
|
||||||
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicio").First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContratosByCliente devuelve contratos de un cliente específico
|
||||||
|
func GetContratosByCliente(clienteID uint) ([]Contrato, error) {
|
||||||
|
var items []Contrato
|
||||||
|
if err := app.Http.Database.DB.Preload("Servicio").
|
||||||
|
Where("cliente_id = ?", clienteID).
|
||||||
|
Order("fecha_vencimiento ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContratosProximosVencer retorna contratos activos que vencen exactamente en N días
|
||||||
|
func GetContratosProximosVencer(diasAntes int) ([]Contrato, error) {
|
||||||
|
var items []Contrato
|
||||||
|
target := time.Now().AddDate(0, 0, diasAntes).UTC()
|
||||||
|
startOfDay := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicio").
|
||||||
|
Where("estado = 'activo' AND fecha_vencimiento >= ? AND fecha_vencimiento < ?", startOfDay, endOfDay).
|
||||||
|
Find(&items).Error; err != nil {
|
||||||
|
log.Printf("Error getting contratos proximos: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateContrato(c Contrato) error {
|
||||||
|
return app.Http.Database.DB.Create(&c).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateContrato(c Contrato) error {
|
||||||
|
return app.Http.Database.DB.Model(&c).Updates(c).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteContrato(id uint) error {
|
||||||
|
var c Contrato
|
||||||
|
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Delete(&c).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificacionLog struct {
|
||||||
|
gorm.Model
|
||||||
|
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id"`
|
||||||
|
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||||
|
ReglaID uint `json:"regla_id" gorm:"column:regla_id"`
|
||||||
|
Regla NotificacionRegla `json:"regla" gorm:"foreignKey:ReglaID"`
|
||||||
|
ContratosIDs string `json:"contratos_ids" gorm:"column:contratos_ids;type:text"` // JSON array
|
||||||
|
FechaEnvio time.Time `json:"fecha_envio" gorm:"column:fecha_envio"`
|
||||||
|
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // enviado | fallido | pendiente
|
||||||
|
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||||
|
Asunto string `json:"asunto" gorm:"column:asunto"`
|
||||||
|
PreviewHTML string `json:"preview_html" gorm:"column:preview_html;type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NotificacionLog) TableName() string { return "notificaciones_log" }
|
||||||
|
|
||||||
|
func GetAllNotificacionLogs(limit, offset int, clienteID uint, estado string) ([]NotificacionLog, int64, error) {
|
||||||
|
var items []NotificacionLog
|
||||||
|
var total int64
|
||||||
|
db := app.Http.Database.DB.Model(&NotificacionLog{}).
|
||||||
|
Preload("Cliente").Preload("Regla")
|
||||||
|
if clienteID > 0 {
|
||||||
|
db = db.Where("cliente_id = ?", clienteID)
|
||||||
|
}
|
||||||
|
if estado != "" {
|
||||||
|
db = db.Where("estado = ?", estado)
|
||||||
|
}
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNotificacionLogByID(id uint) (*NotificacionLog, error) {
|
||||||
|
var item NotificacionLog
|
||||||
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Regla").First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// YaEnviadoHoy comprueba si ya se envió notificación de esta regla a este cliente hoy
|
||||||
|
func YaEnviadoHoy(clienteID, reglaID uint) bool {
|
||||||
|
var count int64
|
||||||
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
tomorrow := today.Add(24 * time.Hour)
|
||||||
|
app.Http.Database.DB.Model(&NotificacionLog{}).
|
||||||
|
Where("cliente_id = ? AND regla_id = ? AND estado = 'enviado' AND created_at >= ? AND created_at < ?",
|
||||||
|
clienteID, reglaID, today, tomorrow).
|
||||||
|
Count(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateNotificacionLog(n NotificacionLog) (*NotificacionLog, error) {
|
||||||
|
if err := app.Http.Database.DB.Create(&n).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateNotificacionLog(n NotificacionLog) error {
|
||||||
|
return app.Http.Database.DB.Model(&n).Updates(map[string]interface{}{
|
||||||
|
"estado": n.Estado,
|
||||||
|
"error_msg": n.ErrorMsg,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aliases simples para los controllers
|
||||||
|
func GetAllLogs(limit, offset int) ([]NotificacionLog, int64, error) {
|
||||||
|
return GetAllNotificacionLogs(limit, offset, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLogByID(id uint) (*NotificacionLog, error) {
|
||||||
|
return GetNotificacionLogByID(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificacionRegla struct {
|
||||||
|
gorm.Model
|
||||||
|
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||||
|
DiasAntes int `json:"dias_antes" gorm:"column:dias_antes"`
|
||||||
|
PlantillaID uint `json:"plantilla_id" gorm:"column:plantilla_id"`
|
||||||
|
Plantilla PlantillaCorreo `json:"plantilla" gorm:"foreignKey:PlantillaID"`
|
||||||
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
AplicaA string `json:"aplica_a" gorm:"column:aplica_a;default:'todos'"` // todos | renovable | unico
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NotificacionRegla) TableName() string { return "notificacion_reglas" }
|
||||||
|
|
||||||
|
func GetAllReglas() ([]NotificacionRegla, error) {
|
||||||
|
var items []NotificacionRegla
|
||||||
|
if err := app.Http.Database.DB.Preload("Plantilla").Order("dias_antes DESC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetReglasActivas() ([]NotificacionRegla, error) {
|
||||||
|
var items []NotificacionRegla
|
||||||
|
if err := app.Http.Database.DB.Preload("Plantilla").
|
||||||
|
Where("activo = ?", true).Order("dias_antes DESC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetReglaByID(id uint) (*NotificacionRegla, error) {
|
||||||
|
var item NotificacionRegla
|
||||||
|
if err := app.Http.Database.DB.Preload("Plantilla").First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRegla(r NotificacionRegla) error {
|
||||||
|
return app.Http.Database.DB.Create(&r).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateRegla(r NotificacionRegla) error {
|
||||||
|
return app.Http.Database.DB.Model(&r).Updates(map[string]interface{}{
|
||||||
|
"nombre": r.Nombre,
|
||||||
|
"dias_antes": r.DiasAntes,
|
||||||
|
"plantilla_id": r.PlantillaID,
|
||||||
|
"activo": r.Activo,
|
||||||
|
"aplica_a": r.AplicaA,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteRegla(id uint) error {
|
||||||
|
var r NotificacionRegla
|
||||||
|
if err := app.Http.Database.DB.First(&r, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Delete(&r).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlantillaCorreo struct {
|
||||||
|
gorm.Model
|
||||||
|
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||||
|
Asunto string `json:"asunto" gorm:"column:asunto"`
|
||||||
|
CuerpoHTML string `json:"cuerpo_html" gorm:"column:cuerpo_html;type:text"`
|
||||||
|
Tipo string `json:"tipo" gorm:"column:tipo;default:'renovacion'"` // renovacion | vencimiento | pago | personalizado
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PlantillaCorreo) TableName() string { return "plantillas_correo" }
|
||||||
|
|
||||||
|
func GetAllPlantillas(limit, offset int, search string) ([]PlantillaCorreo, int64, error) {
|
||||||
|
var items []PlantillaCorreo
|
||||||
|
var total int64
|
||||||
|
db := app.Http.Database.DB.Model(&PlantillaCorreo{})
|
||||||
|
if search != "" {
|
||||||
|
db = db.Where("nombre ILIKE ? OR asunto ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := db.Order("nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllPlantillasSelect() ([]PlantillaCorreo, error) {
|
||||||
|
var items []PlantillaCorreo
|
||||||
|
if err := app.Http.Database.DB.Select("id, nombre, tipo").Order("nombre ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPlantillaByID(id uint) (*PlantillaCorreo, error) {
|
||||||
|
var item PlantillaCorreo
|
||||||
|
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePlantilla(p PlantillaCorreo) error {
|
||||||
|
return app.Http.Database.DB.Create(&p).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePlantilla(id uint, updates map[string]interface{}) error {
|
||||||
|
return app.Http.Database.DB.Model(&PlantillaCorreo{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePlantilla(id uint) error {
|
||||||
|
var p PlantillaCorreo
|
||||||
|
if err := app.Http.Database.DB.First(&p, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Delete(&p).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Servicio struct {
|
||||||
|
gorm.Model
|
||||||
|
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||||
|
Descripcion string `json:"descripcion" gorm:"column:descripcion"`
|
||||||
|
Precio float64 `json:"precio" gorm:"column:precio"`
|
||||||
|
Moneda string `json:"moneda" gorm:"column:moneda;default:'COP'"`
|
||||||
|
Tipo string `json:"tipo" gorm:"column:tipo;default:'renovable'"` // renovable | unico
|
||||||
|
Periodicidad string `json:"periodicidad" gorm:"column:periodicidad"` // mensual | trimestral | semestral | anual
|
||||||
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Servicio) TableName() string { return "servicios" }
|
||||||
|
|
||||||
|
func GetAllServicios(limit, offset int, search string) ([]Servicio, int64, error) {
|
||||||
|
var items []Servicio
|
||||||
|
var total int64
|
||||||
|
db := app.Http.Database.DB.Model(&Servicio{})
|
||||||
|
if search != "" {
|
||||||
|
db = db.Where("nombre ILIKE ? OR descripcion ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllServiciosSelect() ([]Servicio, error) {
|
||||||
|
var items []Servicio
|
||||||
|
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetServicioByID(id uint) (*Servicio, error) {
|
||||||
|
var item Servicio
|
||||||
|
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateServicio(s Servicio) error {
|
||||||
|
return app.Http.Database.DB.Create(&s).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateServicio(s Servicio) error {
|
||||||
|
return app.Http.Database.DB.Model(&s).Updates(s).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteServicio(id uint) error {
|
||||||
|
var s Servicio
|
||||||
|
if err := app.Http.Database.DB.First(&s, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.Http.Database.DB.Delete(&s).Error; err != nil {
|
||||||
|
log.Printf("Error deleting servicio: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SmtpConfig struct {
|
||||||
|
gorm.Model
|
||||||
|
Host string `json:"host" gorm:"column:host"`
|
||||||
|
Port int `json:"port" gorm:"column:port;default:587"`
|
||||||
|
Username string `json:"username" gorm:"column:username"`
|
||||||
|
Password string `json:"password" gorm:"column:password"` // cifrado AES
|
||||||
|
Encryption string `json:"encryption" gorm:"column:encryption;default:'tls'"`
|
||||||
|
FromAddress string `json:"from_address" gorm:"column:from_address"`
|
||||||
|
FromName string `json:"from_name" gorm:"column:from_name"`
|
||||||
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SmtpConfig) TableName() string { return "smtp_config" }
|
||||||
|
|
||||||
|
func GetSmtpConfig() (*SmtpConfig, error) {
|
||||||
|
var item SmtpConfig
|
||||||
|
if err := app.Http.Database.DB.Where("activo = ?", true).First(&item).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveSmtpConfig(s SmtpConfig) error {
|
||||||
|
// Desactivar config previa
|
||||||
|
app.Http.Database.DB.Model(&SmtpConfig{}).Where("activo = ?", true).
|
||||||
|
Update("activo", false)
|
||||||
|
s.Activo = true
|
||||||
|
if s.ID > 0 {
|
||||||
|
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||||
|
"host": s.Host,
|
||||||
|
"port": s.Port,
|
||||||
|
"username": s.Username,
|
||||||
|
"password": s.Password,
|
||||||
|
"encryption": s.Encryption,
|
||||||
|
"from_address": s.FromAddress,
|
||||||
|
"from_name": s.FromName,
|
||||||
|
"activo": true,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Create(&s).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cronScheduler *cron.Cron
|
||||||
|
|
||||||
|
// IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go.
|
||||||
|
func IniciarCron() {
|
||||||
|
cronScheduler = cron.New()
|
||||||
|
|
||||||
|
// Ejecutar todos los días a las 8:00 AM
|
||||||
|
_, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientos)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[CRON] Error registrando tarea: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cronScheduler.Start()
|
||||||
|
log.Println("[CRON] Scheduler iniciado — verificando vencimientos diariamente a las 8:00 AM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetenerCron para graceful shutdown
|
||||||
|
func DetenerCron() {
|
||||||
|
if cronScheduler != nil {
|
||||||
|
cronScheduler.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcesarVencimientos es la función principal del cron
|
||||||
|
func ProcesarVencimientos() {
|
||||||
|
log.Println("[CRON] Iniciando procesamiento de vencimientos...")
|
||||||
|
|
||||||
|
reglas, err := models.GetReglasActivas()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[CRON] Error obteniendo reglas: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, regla := range reglas {
|
||||||
|
contratos, err := models.GetContratosProximosVencer(regla.DiasAntes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(contratos) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar por AplicaA
|
||||||
|
var filtrados []models.Contrato
|
||||||
|
for _, c := range contratos {
|
||||||
|
switch regla.AplicaA {
|
||||||
|
case "renovable":
|
||||||
|
if c.Servicio.Tipo == "renovable" {
|
||||||
|
filtrados = append(filtrados, c)
|
||||||
|
}
|
||||||
|
case "unico":
|
||||||
|
if c.Servicio.Tipo == "unico" {
|
||||||
|
filtrados = append(filtrados, c)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
filtrados = append(filtrados, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtrados) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrupar contratos por cliente
|
||||||
|
porCliente := make(map[uint][]models.Contrato)
|
||||||
|
for _, c := range filtrados {
|
||||||
|
porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c)
|
||||||
|
}
|
||||||
|
|
||||||
|
for clienteID, grupoContratos := range porCliente {
|
||||||
|
// Evitar duplicados: ya enviado hoy para esta regla + cliente
|
||||||
|
if models.YaEnviadoHoy(clienteID, regla.ID) {
|
||||||
|
log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cliente := &grupoContratos[0].Cliente
|
||||||
|
if err := EnviarNotificacionGrupo(®la, cliente, grupoContratos); err != nil {
|
||||||
|
log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)",
|
||||||
|
clienteID, cliente.Email, len(grupoContratos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("[CRON] Procesamiento de vencimientos finalizado")
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DatosPlantilla es el contexto inyectado al renderizar una plantilla de correo
|
||||||
|
type DatosPlantilla struct {
|
||||||
|
ClienteNombre string
|
||||||
|
ClienteEmpresa string
|
||||||
|
ClienteEmail string
|
||||||
|
Servicios []ItemServicio
|
||||||
|
Total float64
|
||||||
|
FechaVencimiento string
|
||||||
|
DiasRestantes int
|
||||||
|
Asunto string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemServicio struct {
|
||||||
|
Nombre string
|
||||||
|
Precio float64
|
||||||
|
Moneda string
|
||||||
|
FechaVenc string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatosEjemplo devuelve datos de prueba para la previsualización de plantillas
|
||||||
|
func DatosEjemplo() DatosPlantilla {
|
||||||
|
return DatosPlantilla{
|
||||||
|
ClienteNombre: "Juan Pérez",
|
||||||
|
ClienteEmpresa: "Empresa Demo S.A.",
|
||||||
|
ClienteEmail: "cliente@ejemplo.com",
|
||||||
|
Servicios: []ItemServicio{
|
||||||
|
{Nombre: "Hosting Basic", Precio: 29.99, Moneda: "USD", FechaVenc: "2025-12-31"},
|
||||||
|
{Nombre: "Dominio .com", Precio: 14.99, Moneda: "USD", FechaVenc: "2025-12-31"},
|
||||||
|
},
|
||||||
|
Total: 44.98,
|
||||||
|
FechaVencimiento: "31/12/2025",
|
||||||
|
DiasRestantes: 15,
|
||||||
|
Asunto: "Recordatorio de vencimiento",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPlantilla renderiza el CuerpoHTML de una PlantillaCorreo con los datos dados
|
||||||
|
func RenderPlantilla(p *models.PlantillaCorreo, datos DatosPlantilla) (string, error) {
|
||||||
|
tmpl, err := template.New("correo").Parse(p.CuerpoHTML)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("plantilla HTML inválida: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, datos); err != nil {
|
||||||
|
return "", fmt.Errorf("error al renderizar plantilla: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnviarCorreoPrueba envía la plantilla con datos de ejemplo al email dado
|
||||||
|
func EnviarCorreoPrueba(email string, p *models.PlantillaCorreo) error {
|
||||||
|
html, err := RenderPlantilla(p, DatosEjemplo())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Http.Mail.Send(email, "[PRUEBA] "+p.Asunto, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnviarCorreoManual envía correo de aviso para un contrato específico
|
||||||
|
func EnviarCorreoManual(contrato *models.Contrato) error {
|
||||||
|
// Usar la primera plantilla activa de tipo renovacion/vencimiento como fallback
|
||||||
|
plantillas, err := models.GetAllPlantillasSelect()
|
||||||
|
if err != nil || len(plantillas) == 0 {
|
||||||
|
return fmt.Errorf("no hay plantillas de correo disponibles")
|
||||||
|
}
|
||||||
|
p, err := models.GetPlantillaByID(plantillas[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dias := int(contrato.FechaVencimiento.Sub(time.Now()).Hours() / 24)
|
||||||
|
datos := DatosPlantilla{
|
||||||
|
ClienteNombre: contrato.Cliente.Nombre,
|
||||||
|
ClienteEmpresa: contrato.Cliente.Empresa,
|
||||||
|
ClienteEmail: contrato.Cliente.Email,
|
||||||
|
FechaVencimiento: contrato.FechaVencimiento.Format("02/01/2006"),
|
||||||
|
DiasRestantes: dias,
|
||||||
|
Total: contrato.PrecioAcordado,
|
||||||
|
Servicios: []ItemServicio{{
|
||||||
|
Nombre: contrato.Servicio.Nombre,
|
||||||
|
Precio: contrato.PrecioAcordado,
|
||||||
|
Moneda: contrato.Servicio.Moneda,
|
||||||
|
FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
html, err := RenderPlantilla(p, datos)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Http.Mail.Send(contrato.Cliente.Email, p.Asunto, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnviarNotificacionGrupo envía un correo agrupado para un cliente con múltiples contratos
|
||||||
|
func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cliente, contratos []models.Contrato) error {
|
||||||
|
p, err := models.GetPlantillaByID(regla.PlantillaID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plantilla no encontrada: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []ItemServicio
|
||||||
|
var total float64
|
||||||
|
var fechaVenc time.Time
|
||||||
|
|
||||||
|
for _, c := range contratos {
|
||||||
|
items = append(items, ItemServicio{
|
||||||
|
Nombre: c.Servicio.Nombre,
|
||||||
|
Precio: c.PrecioAcordado,
|
||||||
|
Moneda: c.Servicio.Moneda,
|
||||||
|
FechaVenc: c.FechaVencimiento.Format("02/01/2006"),
|
||||||
|
})
|
||||||
|
total += c.PrecioAcordado
|
||||||
|
if fechaVenc.IsZero() || c.FechaVencimiento.Before(fechaVenc) {
|
||||||
|
fechaVenc = c.FechaVencimiento
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dias := int(fechaVenc.Sub(time.Now()).Hours() / 24)
|
||||||
|
datos := DatosPlantilla{
|
||||||
|
ClienteNombre: cliente.Nombre,
|
||||||
|
ClienteEmpresa: cliente.Empresa,
|
||||||
|
ClienteEmail: cliente.Email,
|
||||||
|
Servicios: items,
|
||||||
|
Total: total,
|
||||||
|
FechaVencimiento: fechaVenc.Format("02/01/2006"),
|
||||||
|
DiasRestantes: dias,
|
||||||
|
Asunto: p.Asunto,
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := RenderPlantilla(p, datos)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir JSON de IDs de contratos para el log
|
||||||
|
var ids []uint
|
||||||
|
for _, c := range contratos {
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
}
|
||||||
|
idsJSON, _ := json.Marshal(ids)
|
||||||
|
|
||||||
|
// Crear log previo (pendiente)
|
||||||
|
logEntry := models.NotificacionLog{
|
||||||
|
ClienteID: cliente.ID,
|
||||||
|
ReglaID: regla.ID,
|
||||||
|
ContratosIDs: string(idsJSON),
|
||||||
|
FechaEnvio: time.Now(),
|
||||||
|
Estado: "pendiente",
|
||||||
|
Asunto: p.Asunto,
|
||||||
|
PreviewHTML: html,
|
||||||
|
}
|
||||||
|
savedLog, err := models.CreateNotificacionLog(logEntry)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error guardando log de notificación: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar correo (también CC si está definido)
|
||||||
|
sendErr := app.Http.Mail.Send(cliente.Email, p.Asunto, html)
|
||||||
|
if cliente.EmailCC != "" {
|
||||||
|
_ = app.Http.Mail.Send(cliente.EmailCC, "[CC] "+p.Asunto, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar estado del log
|
||||||
|
if savedLog != nil {
|
||||||
|
if sendErr != nil {
|
||||||
|
savedLog.Estado = "fallido"
|
||||||
|
savedLog.ErrorMsg = sendErr.Error()
|
||||||
|
} else {
|
||||||
|
savedLog.Estado = "enviado"
|
||||||
|
}
|
||||||
|
models.UpdateNotificacionLog(*savedLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReenviarLog reenvía un correo ya registrado en el historial
|
||||||
|
func ReenviarLog(logEntry *models.NotificacionLog) error {
|
||||||
|
if logEntry.PreviewHTML == "" {
|
||||||
|
return fmt.Errorf("no hay HTML guardado para este envío")
|
||||||
|
}
|
||||||
|
sendErr := app.Http.Mail.Send(logEntry.Cliente.Email, logEntry.Asunto, logEntry.PreviewHTML)
|
||||||
|
if sendErr != nil {
|
||||||
|
logEntry.Estado = "fallido"
|
||||||
|
logEntry.ErrorMsg = sendErr.Error()
|
||||||
|
} else {
|
||||||
|
logEntry.Estado = "enviado"
|
||||||
|
logEntry.ErrorMsg = ""
|
||||||
|
}
|
||||||
|
models.UpdateNotificacionLog(*logEntry)
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
@@ -77,12 +77,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit -->
|
<!-- Submit -->
|
||||||
<button type="submit" :disabled="loading"
|
<button type="submit"
|
||||||
|
:disabled="loading"
|
||||||
class="w-full py-2.5 rounded-xl text-sm font-semibold text-white transition-all mt-2"
|
class="w-full py-2.5 rounded-xl text-sm font-semibold text-white transition-all mt-2"
|
||||||
style="background-color:#8eb02f"
|
:class="loading ? 'opacity-70 cursor-not-allowed' : 'hover:opacity-90'"
|
||||||
:style="loading ? 'opacity:0.7; cursor:not-allowed' : ''"
|
style="background-color:#8eb02f">
|
||||||
onmouseover="if(!this.disabled) this.style.backgroundColor='#6d8c24'"
|
|
||||||
onmouseout="if(!this.disabled) this.style.backgroundColor='#8eb02f'">
|
|
||||||
<span x-show="!loading" class="flex items-center justify-center gap-2">
|
<span x-show="!loading" class="flex items-center justify-center gap-2">
|
||||||
Iniciar sesión
|
Iniciar sesión
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
<div class="justify-between items-center w-full md:flex mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Clientes</h1>
|
||||||
|
<p class="text-sm text-gray-500">Gestión de clientes y sus datos de contacto</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3 md:mt-0">
|
||||||
|
<input type="text" placeholder="Buscar..." class="border border-gray-300 rounded p-2 text-sm"
|
||||||
|
x-model="search" @input.debounce.400ms="loadData()" />
|
||||||
|
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm">
|
||||||
|
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Nombre</th>
|
||||||
|
<th class="py-2 px-3">Empresa</th>
|
||||||
|
<th class="py-2 px-3">Email</th>
|
||||||
|
<th class="py-2 px-3">Teléfono</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3 w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600">
|
||||||
|
<template x-for="d in datos" :key="d.ID">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="d.empresa||'—'"></td>
|
||||||
|
<td class="py-2 px-3" x-text="d.email"></td>
|
||||||
|
<td class="py-2 px-3" x-text="d.telefono||'—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="d.activo ? 'text-green-600' : 'text-red-500'" x-text="d.activo ? 'Activo' : 'Inactivo'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openEdit(d)" class="text-gray-400 hover:text-[#8eb02f]">
|
||||||
|
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="openDelete(d)" 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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && datos.length===0">
|
||||||
|
<td colspan="6" class="text-center text-gray-400 py-8">Sin registros</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
|
||||||
|
<span>Total: <span x-text="total"></span></span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40">‹</button>
|
||||||
|
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
|
||||||
|
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear / Editar -->
|
||||||
|
<div x-show="addModal || editModal" 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-lg mx-4 p-6" @click.stop>
|
||||||
|
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Cliente' : 'Nuevo Cliente'"></h2>
|
||||||
|
<form @submit.prevent="save()">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Nombre *</label>
|
||||||
|
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Empresa</label>
|
||||||
|
<input x-model="form.empresa" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Email *</label>
|
||||||
|
<input x-model="form.email" type="email" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Email CC</label>
|
||||||
|
<input x-model="form.email_cc" type="email" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Teléfono</label>
|
||||||
|
<input x-model="form.telefono" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Documento</label>
|
||||||
|
<input x-model="form.documento" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Notas</label>
|
||||||
|
<textarea x-model="form.notas" rows="2" class="mt-1 w-full border rounded px-3 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" x-model="form.activo" id="activo_cli" />
|
||||||
|
<label for="activo_cli" class="text-sm">Activo</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5">
|
||||||
|
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar -->
|
||||||
|
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
|
||||||
|
<svg class="w-12 h-12 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/></svg>
|
||||||
|
<p class="font-semibold mb-1">¿Eliminar cliente?</p>
|
||||||
|
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
|
||||||
|
<div class="flex justify-center gap-3">
|
||||||
|
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false,
|
||||||
|
datos: [], total: 0, totalPages: 1, page: 1, limit: 10,
|
||||||
|
search: '',
|
||||||
|
addModal: false, editModal: false, deleteModal: false,
|
||||||
|
selectedId: null,
|
||||||
|
form: { nombre:'', empresa:'', email:'', email_cc:'', telefono:'', documento:'', notas:'', activo:true },
|
||||||
|
toast: { show: false, msg: '', type: 'ok' },
|
||||||
|
|
||||||
|
async init() { await this.loadData(); },
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
const { data } = await axios.get(`/app/api/clientes?page=${this.page}&limit=${this.limit}&search=${this.search}`);
|
||||||
|
this.datos = data.registros || [];
|
||||||
|
this.total = data.total || 0;
|
||||||
|
this.totalPages = data.totalPages || 1;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(d) {
|
||||||
|
this.form = { nombre: d.nombre, empresa: d.empresa, email: d.email, email_cc: d.email_cc, telefono: d.telefono, documento: d.documento, notas: d.notas, activo: d.activo };
|
||||||
|
this.selectedId = d.ID;
|
||||||
|
this.editModal = true;
|
||||||
|
},
|
||||||
|
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||||
|
closeModals() {
|
||||||
|
this.addModal = this.editModal = this.deleteModal = false;
|
||||||
|
this.selectedId = null;
|
||||||
|
this.form = { nombre:'', empresa:'', email:'', email_cc:'', telefono:'', documento:'', notas:'', activo:true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
if (this.editModal) {
|
||||||
|
await axios.put(`/app/api/clientes/${this.selectedId}`, this.form);
|
||||||
|
} else {
|
||||||
|
await axios.post('/app/api/clientes', this.form);
|
||||||
|
}
|
||||||
|
this.showToast('Guardado correctamente');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/app/api/clientes/${this.selectedId}`);
|
||||||
|
this.showToast('Eliminado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
|
||||||
|
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
|
||||||
|
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show: true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
<div class="justify-between items-center w-full md:flex mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Contratos</h1>
|
||||||
|
<p class="text-sm text-gray-500">Gestión de contratos y vencimientos</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3 md:mt-0 flex-wrap">
|
||||||
|
<select x-model="filtroEstado" @change="loadData()" class="border border-gray-300 rounded p-2 text-sm">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="activo">Activo</option>
|
||||||
|
<option value="vencido">Vencido</option>
|
||||||
|
<option value="cancelado">Cancelado</option>
|
||||||
|
<option value="renovado">Renovado</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" placeholder="Buscar cliente..." class="border border-gray-300 rounded p-2 text-sm"
|
||||||
|
x-model="search" @input.debounce.400ms="loadData()" />
|
||||||
|
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm">
|
||||||
|
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Cliente</th>
|
||||||
|
<th class="py-2 px-3">Servicio</th>
|
||||||
|
<th class="py-2 px-3">Vencimiento</th>
|
||||||
|
<th class="py-2 px-3">Días</th>
|
||||||
|
<th class="py-2 px-3">Precio</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3 w-32"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600">
|
||||||
|
<template x-for="d in datos" :key="d.ID">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="font-medium" x-text="d.cliente?.nombre||'—'"></div>
|
||||||
|
<div class="text-xs text-gray-400" x-text="d.cliente?.empresa||''"></div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3" x-text="d.servicio?.nombre||'—'"></td>
|
||||||
|
<td class="py-2 px-3" x-text="fmtDate(d.fecha_vencimiento)"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-semibold"
|
||||||
|
:class="d.urgencia==='rojo' ? 'bg-red-100 text-red-700' : d.urgencia==='amarillo' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'"
|
||||||
|
x-text="d.dias_restantes <= 0 ? 'Vencido' : d.dias_restantes+' días'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3" x-text="(d.servicio?.moneda||'') +' '+ Number(d.precio_acordado).toFixed(2)"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-700': d.estado==='activo',
|
||||||
|
'bg-red-100 text-red-700': d.estado==='vencido',
|
||||||
|
'bg-gray-100 text-gray-600': d.estado==='cancelado',
|
||||||
|
'bg-blue-100 text-blue-700': d.estado==='renovado'
|
||||||
|
}"
|
||||||
|
x-text="d.estado"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
|
||||||
|
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="renovar(d)" title="Renovar" x-show="d.estado==='activo'" class="text-gray-400 hover:text-blue-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="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>
|
||||||
|
</button>
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && datos.length===0">
|
||||||
|
<td colspan="7" class="text-center text-gray-400 py-8">Sin registros</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
|
||||||
|
<span>Total: <span x-text="total"></span></span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40">‹</button>
|
||||||
|
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
|
||||||
|
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear / Editar -->
|
||||||
|
<div x-show="addModal || editModal" 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-lg mx-4 p-6" @click.stop>
|
||||||
|
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Contrato' : 'Nuevo Contrato'"></h2>
|
||||||
|
<form @submit.prevent="save()">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="col-span-2" x-show="!editModal">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Cliente *</label>
|
||||||
|
<select x-model="form.cliente_id" required class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<template x-for="c in clientes" :key="c.ID">
|
||||||
|
<option :value="c.ID" x-text="c.nombre + (c.empresa ? ' — '+c.empresa : '')"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2" x-show="!editModal">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Servicio *</label>
|
||||||
|
<select x-model="form.servicio_id" required class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<template x-for="s in servicios" :key="s.ID">
|
||||||
|
<option :value="s.ID" x-text="s.nombre"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Fecha inicio *</label>
|
||||||
|
<input x-model="form.fecha_inicio" type="date" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Fecha vencimiento *</label>
|
||||||
|
<input x-model="form.fecha_vencimiento" type="date" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Precio acordado</label>
|
||||||
|
<input x-model="form.precio_acordado" type="number" step="0.01" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div x-show="editModal">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Estado</label>
|
||||||
|
<select x-model="form.estado" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="activo">Activo</option>
|
||||||
|
<option value="vencido">Vencido</option>
|
||||||
|
<option value="cancelado">Cancelado</option>
|
||||||
|
<option value="renovado">Renovado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Notas</label>
|
||||||
|
<textarea x-model="form.notas" rows="2" class="mt-1 w-full border rounded px-3 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" x-model="form.auto_renovar" id="auto_ren" />
|
||||||
|
<label for="auto_ren" class="text-sm">Auto-renovar</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5">
|
||||||
|
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar -->
|
||||||
|
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
|
||||||
|
<svg class="w-12 h-12 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/></svg>
|
||||||
|
<p class="font-semibold mb-1">¿Eliminar contrato?</p>
|
||||||
|
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
|
||||||
|
<div class="flex justify-center gap-3">
|
||||||
|
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false,
|
||||||
|
datos: [], total: 0, totalPages: 1, page: 1, limit: 10,
|
||||||
|
search: '', filtroEstado: '',
|
||||||
|
addModal: false, editModal: false, deleteModal: false,
|
||||||
|
clientes: [], servicios: [],
|
||||||
|
selectedId: null,
|
||||||
|
form: { cliente_id:'', servicio_id:'', fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' },
|
||||||
|
toast: { show: false, msg: '', type: 'ok' },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await Promise.all([this.loadData(), this.loadSelects()]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
const { data } = await axios.get(`/app/api/contratos?page=${this.page}&limit=${this.limit}&search=${this.search}&estado=${this.filtroEstado}`);
|
||||||
|
this.datos = data.registros || [];
|
||||||
|
this.total = data.total || 0;
|
||||||
|
this.totalPages = data.totalPages || 1;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSelects() {
|
||||||
|
const [c, s] = await Promise.all([
|
||||||
|
axios.get('/app/api/clientes/select'),
|
||||||
|
axios.get('/app/api/servicios/select'),
|
||||||
|
]);
|
||||||
|
this.clientes = c.data.registros || [];
|
||||||
|
this.servicios = s.data.registros || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(d) {
|
||||||
|
this.form = {
|
||||||
|
fecha_inicio: d.fecha_inicio ? d.fecha_inicio.substring(0,10) : '',
|
||||||
|
fecha_vencimiento: d.fecha_vencimiento ? d.fecha_vencimiento.substring(0,10) : '',
|
||||||
|
precio_acordado: d.precio_acordado,
|
||||||
|
estado: d.estado,
|
||||||
|
auto_renovar: d.auto_renovar,
|
||||||
|
notas: d.notas
|
||||||
|
};
|
||||||
|
this.selectedId = d.ID;
|
||||||
|
this.editModal = true;
|
||||||
|
},
|
||||||
|
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||||
|
closeModals() {
|
||||||
|
this.addModal = this.editModal = this.deleteModal = false;
|
||||||
|
this.selectedId = null;
|
||||||
|
this.form = { cliente_id:'', servicio_id:'', fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' };
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
if (this.editModal) {
|
||||||
|
await axios.put(`/app/api/contratos/${this.selectedId}`, this.form);
|
||||||
|
} else {
|
||||||
|
await axios.post('/app/api/contratos', this.form);
|
||||||
|
}
|
||||||
|
this.showToast('Guardado correctamente');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/app/api/contratos/${this.selectedId}`);
|
||||||
|
this.showToast('Eliminado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async renovar(d) {
|
||||||
|
if (!confirm(`¿Renovar el contrato de ${d.cliente?.nombre}?`)) return;
|
||||||
|
try {
|
||||||
|
await axios.post(`/app/api/contratos/${d.ID}/renovar`);
|
||||||
|
this.showToast('Contrato renovado');
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async enviarCorreo(d) {
|
||||||
|
if (!confirm(`¿Enviar correo de aviso a ${d.cliente?.email}?`)) return;
|
||||||
|
try {
|
||||||
|
await axios.post(`/app/api/contratos/${d.ID}/enviar-correo`);
|
||||||
|
this.showToast('Correo enviado');
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtDate(raw) {
|
||||||
|
if (!raw) return '—';
|
||||||
|
return new Date(raw).toLocaleDateString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric' });
|
||||||
|
},
|
||||||
|
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
|
||||||
|
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show: true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
<div class="justify-between items-center w-full md:flex mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Historial de Notificaciones</h1>
|
||||||
|
<p class="text-sm text-gray-500">Registro de correos enviados por el sistema</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm">
|
||||||
|
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Cliente</th>
|
||||||
|
<th class="py-2 px-3">Regla</th>
|
||||||
|
<th class="py-2 px-3">Asunto</th>
|
||||||
|
<th class="py-2 px-3">Fecha</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3 w-28"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600">
|
||||||
|
<template x-for="d in datos" :key="d.ID">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="font-medium" x-text="d.cliente?.nombre||'—'"></div>
|
||||||
|
<div class="text-xs text-gray-400" x-text="d.cliente?.email||''"></div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-gray-500 text-xs" x-text="d.regla?.nombre||'—'"></td>
|
||||||
|
<td class="py-2 px-3 max-w-xs truncate" x-text="d.asunto"></td>
|
||||||
|
<td class="py-2 px-3 text-xs" x-text="fmtDate(d.fecha_envio)"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-700': d.estado==='enviado',
|
||||||
|
'bg-red-100 text-red-700': d.estado==='fallido',
|
||||||
|
'bg-yellow-100 text-yellow-700': d.estado==='pendiente'
|
||||||
|
}"
|
||||||
|
x-text="d.estado"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="verPreview(d)" title="Ver correo" class="text-gray-400 hover:text-blue-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="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="reenviar(d)" title="Reenviar" 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="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && datos.length===0">
|
||||||
|
<td colspan="6" class="text-center text-gray-400 py-8">Sin registros</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
|
||||||
|
<span>Total: <span x-text="total"></span></span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40">‹</button>
|
||||||
|
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
|
||||||
|
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal preview -->
|
||||||
|
<div x-show="previewModal" 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-2xl mx-4 p-5 max-h-[90vh] overflow-y-auto" @click.stop>
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Vista del correo</h2>
|
||||||
|
<p class="text-xs text-gray-400" x-text="previewAsunto"></p>
|
||||||
|
</div>
|
||||||
|
<button @click="previewModal=false" class="text-gray-400 hover:text-gray-700">✕</button>
|
||||||
|
</div>
|
||||||
|
<iframe x-ref="previewFrame" class="w-full border rounded" style="height:500px"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false,
|
||||||
|
datos: [], total: 0, totalPages: 1, page: 1, limit: 20,
|
||||||
|
previewModal: false, previewAsunto: '',
|
||||||
|
toast: { show:false, msg:'', type:'ok' },
|
||||||
|
|
||||||
|
async init() { await this.loadData(); },
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
const { data } = await axios.get(`/app/api/historial-notificaciones?page=${this.page}&limit=${this.limit}`);
|
||||||
|
this.datos = data.registros || [];
|
||||||
|
this.total = data.total || 0;
|
||||||
|
this.totalPages = data.totalPages || 1;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async verPreview(d) {
|
||||||
|
const { data } = await axios.get(`/app/api/historial-notificaciones/${d.ID}/preview`);
|
||||||
|
this.previewAsunto = data.asunto || '';
|
||||||
|
this.previewModal = true;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.previewFrame.srcdoc = data.html || '<p style="padding:1rem;color:#999">Sin contenido guardado</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async reenviar(d) {
|
||||||
|
if (!confirm(`¿Reenviar correo a ${d.cliente?.email}?`)) return;
|
||||||
|
try {
|
||||||
|
await axios.post(`/app/api/historial-notificaciones/${d.ID}/reenviar`);
|
||||||
|
this.showToast('Reenviado correctamente');
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtDate(raw) {
|
||||||
|
if (!raw) return '—';
|
||||||
|
return new Date(raw).toLocaleString('es-ES', { dateStyle:'short', timeStyle:'short' });
|
||||||
|
},
|
||||||
|
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
|
||||||
|
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show:true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
<div class="justify-between items-center w-full md:flex mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Plantillas de Correo</h1>
|
||||||
|
<p class="text-sm text-gray-500">Editor de plantillas HTML para notificaciones</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3 md:mt-0">
|
||||||
|
<input type="text" placeholder="Buscar..." class="border border-gray-300 rounded p-2 text-sm"
|
||||||
|
x-model="search" @input.debounce.400ms="loadData()" />
|
||||||
|
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm">
|
||||||
|
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Nombre</th>
|
||||||
|
<th class="py-2 px-3">Asunto</th>
|
||||||
|
<th class="py-2 px-3">Tipo</th>
|
||||||
|
<th class="py-2 px-3 w-36"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600">
|
||||||
|
<template x-for="d in datos" :key="d.ID">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="d.asunto"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700" x-text="d.tipo"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openPreview(d)" title="Vista previa" class="text-gray-400 hover:text-blue-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="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="openTest(d)" title="Enviar prueba" 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="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
|
||||||
|
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && datos.length===0">
|
||||||
|
<td colspan="4" class="text-center text-gray-400 py-8">Sin plantillas</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
|
||||||
|
<span>Total: <span x-text="total"></span></span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40">‹</button>
|
||||||
|
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
|
||||||
|
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear / Editar (con editor HTML) -->
|
||||||
|
<div x-show="addModal || editModal" 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-4xl mx-4 p-6 max-h-[90vh] overflow-y-auto" @click.stop>
|
||||||
|
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2>
|
||||||
|
|
||||||
|
<!-- Variables disponibles -->
|
||||||
|
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
|
||||||
|
<strong>Variables disponibles:</strong>
|
||||||
|
<code>{{.ClienteNombre}}</code>,
|
||||||
|
<code>{{.ClienteEmpresa}}</code>,
|
||||||
|
<code>{{.FechaVencimiento}}</code>,
|
||||||
|
<code>{{.DiasRestantes}}</code>,
|
||||||
|
<code>{{.Total}}</code>,
|
||||||
|
<code>{{range .Servicios}}...{{.Nombre}} {{.Precio}}...{{end}}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="save()">
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Nombre *</label>
|
||||||
|
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Tipo</label>
|
||||||
|
<select x-model="form.tipo" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="renovacion">Renovación</option>
|
||||||
|
<option value="vencimiento">Vencimiento</option>
|
||||||
|
<option value="pago">Pago</option>
|
||||||
|
<option value="personalizado">Personalizado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Asunto *</label>
|
||||||
|
<input x-model="form.asunto" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600 block mb-1">HTML del correo *</label>
|
||||||
|
<textarea x-model="form.cuerpo_html" rows="16"
|
||||||
|
@input.debounce.600ms="previewInline()"
|
||||||
|
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600 block mb-1">Vista previa</label>
|
||||||
|
<iframe x-ref="previewFrame" class="w-full border rounded" style="height:352px"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5">
|
||||||
|
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal vista previa del servidor -->
|
||||||
|
<div x-show="previewModal" 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-2xl mx-4 p-5 max-h-[90vh] overflow-y-auto" @click.stop>
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h2 class="text-lg font-semibold">Vista previa</h2>
|
||||||
|
<button @click="previewModal=false" class="text-gray-400 hover:text-gray-700">✕</button>
|
||||||
|
</div>
|
||||||
|
<iframe x-ref="serverPreviewFrame" class="w-full border rounded" style="height:500px"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Enviar prueba -->
|
||||||
|
<div x-show="testModal" 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>
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Enviar correo de prueba</h2>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Email destino *</label>
|
||||||
|
<input x-model="testEmail" type="email" class="mt-1 w-full border rounded px-3 py-2 text-sm mb-4" placeholder="correo@ejemplo.com" />
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="testModal=false" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button @click="sendTest()" class="px-4 py-2 bg-purple-600 text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Enviando…' : 'Enviar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar -->
|
||||||
|
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
|
||||||
|
<p class="font-semibold mb-1">¿Eliminar plantilla?</p>
|
||||||
|
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
|
||||||
|
<div class="flex justify-center gap-3">
|
||||||
|
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false,
|
||||||
|
datos: [], total: 0, totalPages: 1, page: 1, limit: 10, search: '',
|
||||||
|
addModal: false, editModal: false, deleteModal: false, previewModal: false, testModal: false,
|
||||||
|
selectedId: null, testEmail: '',
|
||||||
|
form: { nombre:'', asunto:'', cuerpo_html:'', tipo:'renovacion' },
|
||||||
|
toast: { show:false, msg:'', type:'ok' },
|
||||||
|
|
||||||
|
async init() { await this.loadData(); },
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
const { data } = await axios.get(`/app/api/plantillas-correo?page=${this.page}&limit=${this.limit}&search=${this.search}`);
|
||||||
|
this.datos = data.registros || [];
|
||||||
|
this.total = data.total || 0;
|
||||||
|
this.totalPages = data.totalPages || 1;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(d) {
|
||||||
|
this.form = { nombre: d.nombre, asunto: d.asunto, cuerpo_html: d.cuerpo_html, tipo: d.tipo };
|
||||||
|
this.selectedId = d.ID;
|
||||||
|
this.editModal = true;
|
||||||
|
this.$nextTick(() => this.previewInline());
|
||||||
|
},
|
||||||
|
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||||
|
openTest(d) { this.selectedId = d.ID; this.testModal = true; },
|
||||||
|
|
||||||
|
async openPreview(d) {
|
||||||
|
this.selectedId = d.ID;
|
||||||
|
const { data } = await axios.get(`/app/api/plantillas-correo/${d.ID}/preview`);
|
||||||
|
this.previewModal = true;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const frame = this.$refs.serverPreviewFrame;
|
||||||
|
frame.srcdoc = data.html || '<p>Sin contenido</p>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
previewInline() {
|
||||||
|
const frame = this.$refs.previewFrame;
|
||||||
|
if (frame) frame.srcdoc = this.form.cuerpo_html || '<p style="color:#999;padding:1rem">Sin contenido</p>';
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModals() {
|
||||||
|
this.addModal = this.editModal = this.deleteModal = this.previewModal = this.testModal = false;
|
||||||
|
this.selectedId = null;
|
||||||
|
this.form = { nombre:'', asunto:'', cuerpo_html:'', tipo:'renovacion' };
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
if (this.editModal) {
|
||||||
|
await axios.put(`/app/api/plantillas-correo/${this.selectedId}`, this.form);
|
||||||
|
} else {
|
||||||
|
await axios.post('/app/api/plantillas-correo', this.form);
|
||||||
|
}
|
||||||
|
this.showToast('Guardado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/app/api/plantillas-correo/${this.selectedId}`);
|
||||||
|
this.showToast('Eliminado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendTest() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.post(`/app/api/plantillas-correo/${this.selectedId}/test`, { email: this.testEmail });
|
||||||
|
this.showToast('Correo de prueba enviado');
|
||||||
|
this.testModal = false;
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
|
||||||
|
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show:true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
<div class="justify-between items-center w-full md:flex mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Reglas de Notificación</h1>
|
||||||
|
<p class="text-sm text-gray-500">Define cuándo y cómo se envían las alertas automáticas</p>
|
||||||
|
</div>
|
||||||
|
<button @click="addModal = true" class="mt-3 md:mt-0 bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva regla</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm">
|
||||||
|
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Nombre</th>
|
||||||
|
<th class="py-2 px-3">Días antes</th>
|
||||||
|
<th class="py-2 px-3">Plantilla</th>
|
||||||
|
<th class="py-2 px-3">Aplica a</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3 w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600">
|
||||||
|
<template x-for="d in datos" :key="d.ID">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs font-semibold" x-text="d.dias_antes+' días'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="d.plantilla?.nombre||'—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600" x-text="d.aplica_a"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<button @click="toggleActivo(d)"
|
||||||
|
:class="d.activo ? 'bg-green-500' : 'bg-gray-300'"
|
||||||
|
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors">
|
||||||
|
<span :class="d.activo ? 'translate-x-5' : 'translate-x-1'"
|
||||||
|
class="inline-block h-3 w-3 bg-white rounded-full transition-transform"></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openEdit(d)" class="text-gray-400 hover:text-[#8eb02f]">
|
||||||
|
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="openDelete(d)" 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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && datos.length===0">
|
||||||
|
<td colspan="6" class="text-center text-gray-400 py-8">Sin reglas configuradas</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear / Editar -->
|
||||||
|
<div x-show="addModal || editModal" 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-md mx-4 p-6" @click.stop>
|
||||||
|
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Regla' : 'Nueva Regla'"></h2>
|
||||||
|
<form @submit.prevent="save()">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Nombre *</label>
|
||||||
|
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Días antes del vencimiento *</label>
|
||||||
|
<input x-model="form.dias_antes" type="number" min="1" max="365" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Plantilla de correo *</label>
|
||||||
|
<select x-model="form.plantilla_id" required class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<template x-for="p in plantillas" :key="p.ID">
|
||||||
|
<option :value="p.ID" x-text="p.nombre"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Aplica a</label>
|
||||||
|
<select x-model="form.aplica_a" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="renovable">Solo renovables</option>
|
||||||
|
<option value="unico">Solo únicos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" x-model="form.activo" id="activo_regla" />
|
||||||
|
<label for="activo_regla" class="text-sm">Activa</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5">
|
||||||
|
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar -->
|
||||||
|
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
|
||||||
|
<p class="font-semibold mb-1">¿Eliminar regla?</p>
|
||||||
|
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
|
||||||
|
<div class="flex justify-center gap-3">
|
||||||
|
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false, datos: [], plantillas: [],
|
||||||
|
addModal: false, editModal: false, deleteModal: false,
|
||||||
|
selectedId: null,
|
||||||
|
form: { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', activo:true },
|
||||||
|
toast: { show:false, msg:'', type:'ok' },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const [r, p] = await Promise.all([
|
||||||
|
axios.get('/app/api/reglas-notificacion'),
|
||||||
|
axios.get('/app/api/plantillas-correo?limit=100')
|
||||||
|
]);
|
||||||
|
this.datos = r.data.registros || [];
|
||||||
|
this.plantillas = p.data.registros || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
const { data } = await axios.get('/app/api/reglas-notificacion');
|
||||||
|
this.datos = data.registros || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(d) {
|
||||||
|
this.form = { nombre: d.nombre, dias_antes: d.dias_antes, plantilla_id: d.plantilla_id, aplica_a: d.aplica_a, activo: d.activo };
|
||||||
|
this.selectedId = d.ID;
|
||||||
|
this.editModal = true;
|
||||||
|
},
|
||||||
|
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||||
|
closeModals() {
|
||||||
|
this.addModal = this.editModal = this.deleteModal = false;
|
||||||
|
this.selectedId = null;
|
||||||
|
this.form = { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', activo:true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleActivo(d) {
|
||||||
|
await axios.put(`/app/api/reglas-notificacion/${d.ID}`, { ...d, activo: !d.activo });
|
||||||
|
await this.loadData();
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
if (this.editModal) {
|
||||||
|
await axios.put(`/app/api/reglas-notificacion/${this.selectedId}`, this.form);
|
||||||
|
} else {
|
||||||
|
await axios.post('/app/api/reglas-notificacion', this.form);
|
||||||
|
}
|
||||||
|
this.showToast('Guardado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/app/api/reglas-notificacion/${this.selectedId}`);
|
||||||
|
this.showToast('Eliminado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show:true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="justify-between items-center w-full md:flex mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Servicios</h1>
|
||||||
|
<p class="text-sm text-gray-500">Catálogo de servicios ofrecidos</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3 md:mt-0">
|
||||||
|
<input type="text" placeholder="Buscar..." class="border border-gray-300 rounded p-2 text-sm"
|
||||||
|
x-model="search" @input.debounce.400ms="loadData()" />
|
||||||
|
<button @click="addModal = true"
|
||||||
|
class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm">
|
||||||
|
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Nombre</th>
|
||||||
|
<th class="py-2 px-3">Tipo</th>
|
||||||
|
<th class="py-2 px-3">Periodicidad</th>
|
||||||
|
<th class="py-2 px-3">Precio</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3 w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600">
|
||||||
|
<template x-for="d in datos" :key="d.ID">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="d.tipo==='renovable' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'"
|
||||||
|
x-text="d.tipo"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3" x-text="d.periodicidad || '—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span x-text="d.moneda+' '+Number(d.precio).toFixed(2)"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="d.activo ? 'text-green-600' : 'text-red-500'" x-text="d.activo ? 'Activo' : 'Inactivo'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
|
||||||
|
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && datos.length===0">
|
||||||
|
<td colspan="6" class="text-center text-gray-400 py-8">Sin registros</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación -->
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
|
||||||
|
<span>Total: <span x-text="total"></span></span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40">‹</button>
|
||||||
|
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
|
||||||
|
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear / Editar -->
|
||||||
|
<div x-show="addModal || editModal" 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-lg mx-4 p-6" @click.stop>
|
||||||
|
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Servicio' : 'Nuevo Servicio'"></h2>
|
||||||
|
<form @submit.prevent="save()">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Nombre *</label>
|
||||||
|
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Descripción</label>
|
||||||
|
<textarea x-model="form.descripcion" rows="2" class="mt-1 w-full border rounded px-3 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Precio *</label>
|
||||||
|
<input x-model="form.precio" type="number" step="0.01" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Moneda</label>
|
||||||
|
<select x-model="form.moneda" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option>USD</option><option>EUR</option><option>COP</option><option>MXN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Tipo</label>
|
||||||
|
<select x-model="form.tipo" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="renovable">Renovable</option>
|
||||||
|
<option value="unico">Único</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="form.tipo==='renovable'">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Periodicidad</label>
|
||||||
|
<select x-model="form.periodicidad" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="mensual">Mensual</option>
|
||||||
|
<option value="trimestral">Trimestral</option>
|
||||||
|
<option value="semestral">Semestral</option>
|
||||||
|
<option value="anual">Anual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<input type="checkbox" x-model="form.activo" id="activo_svc" />
|
||||||
|
<label for="activo_svc" class="text-sm">Activo</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5">
|
||||||
|
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar -->
|
||||||
|
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
|
||||||
|
<svg class="w-12 h-12 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/></svg>
|
||||||
|
<p class="font-semibold text-gray-800 mb-1">¿Eliminar servicio?</p>
|
||||||
|
<p class="text-sm text-gray-500 mb-5">Esta acción no se puede deshacer.</p>
|
||||||
|
<div class="flex justify-center gap-3">
|
||||||
|
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
|
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm" :disabled="loading">
|
||||||
|
<span x-text="loading ? 'Eliminando…' : 'Eliminar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false,
|
||||||
|
datos: [], total: 0, totalPages: 1, page: 1, limit: 10,
|
||||||
|
search: '',
|
||||||
|
addModal: false, editModal: false, deleteModal: false,
|
||||||
|
selectedId: null,
|
||||||
|
form: { nombre:'', descripcion:'', precio:0, moneda:'USD', tipo:'renovable', periodicidad:'anual', activo:true },
|
||||||
|
toast: { show: false, msg: '', type: 'ok' },
|
||||||
|
|
||||||
|
async init() { await this.loadData(); },
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
const { data } = await axios.get(`/app/api/servicios?page=${this.page}&limit=${this.limit}&search=${this.search}`);
|
||||||
|
this.datos = data.registros || [];
|
||||||
|
this.total = data.total || 0;
|
||||||
|
this.totalPages = data.totalPages || 1;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(d) {
|
||||||
|
this.form = { nombre: d.nombre, descripcion: d.descripcion, precio: d.precio, moneda: d.moneda, tipo: d.tipo, periodicidad: d.periodicidad, activo: d.activo };
|
||||||
|
this.selectedId = d.ID;
|
||||||
|
this.editModal = true;
|
||||||
|
},
|
||||||
|
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||||
|
closeModals() {
|
||||||
|
this.addModal = this.editModal = this.deleteModal = false;
|
||||||
|
this.selectedId = null;
|
||||||
|
this.form = { nombre:'', descripcion:'', precio:0, moneda:'USD', tipo:'renovable', periodicidad:'anual', activo:true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
if (this.editModal) {
|
||||||
|
await axios.put(`/app/api/servicios/${this.selectedId}`, this.form);
|
||||||
|
} else {
|
||||||
|
await axios.post('/app/api/servicios', this.form);
|
||||||
|
}
|
||||||
|
this.showToast('Guardado correctamente');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/app/api/servicios/${this.selectedId}`);
|
||||||
|
this.showToast('Eliminado');
|
||||||
|
this.closeModals();
|
||||||
|
await this.loadData();
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
|
||||||
|
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
|
||||||
|
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show: true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<div x-data="app" class="bg-white rounded-lg shadow">
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full max-w-xl">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Configuración SMTP</h1>
|
||||||
|
<p class="text-sm text-gray-500">Servidor de correo saliente para las notificaciones automáticas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="save()" class="space-y-4 bg-gray-50 rounded-lg p-5 border border-gray-200">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="col-span-2 md:col-span-1">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Host SMTP *</label>
|
||||||
|
<input x-model="form.host" required placeholder="smtp.gmail.com"
|
||||||
|
class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Puerto *</label>
|
||||||
|
<input x-model="form.port" type="number" required placeholder="587"
|
||||||
|
class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Usuario / Email *</label>
|
||||||
|
<input x-model="form.username" type="email" required
|
||||||
|
class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Contraseña</label>
|
||||||
|
<input x-model="form.password" type="password"
|
||||||
|
placeholder="Dejar vacío para no cambiar"
|
||||||
|
class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Cifrado</label>
|
||||||
|
<select x-model="form.encryption" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="tls">TLS (STARTTLS)</option>
|
||||||
|
<option value="ssl">SSL</option>
|
||||||
|
<option value="none">Sin cifrado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Nombre remitente</label>
|
||||||
|
<input x-model="form.from_name" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Email remitente *</label>
|
||||||
|
<input x-model="form.from_address" type="email" required
|
||||||
|
class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-2 border-t">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" x-model="testEmail" placeholder="Email de prueba"
|
||||||
|
class="border rounded px-3 py-1.5 text-sm w-56" />
|
||||||
|
<button type="button" @click="testSMTP()"
|
||||||
|
class="px-3 py-1.5 border border-purple-300 text-purple-700 rounded text-sm hover:bg-purple-50"
|
||||||
|
:disabled="loading">
|
||||||
|
<span x-text="loading ? 'Enviando…' : 'Probar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-5 py-2 bg-[#8eb02f] text-white rounded text-sm font-medium"
|
||||||
|
:disabled="loading">
|
||||||
|
<span x-text="loading ? 'Guardando…' : 'Guardar configuración'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Estado actual -->
|
||||||
|
<div class="mt-5 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||||
|
<strong>Nota:</strong> Al guardar, la configuración se aplica inmediatamente sin necesidad de reiniciar el servidor.
|
||||||
|
La contraseña se almacena cifrada con AES-256.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast.show" x-cloak x-transition
|
||||||
|
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||||||
|
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||||||
|
x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
loading: false,
|
||||||
|
testEmail: '',
|
||||||
|
form: { host:'', port:587, username:'', password:'', encryption:'tls', from_name:'', from_address:'' },
|
||||||
|
toast: { show:false, msg:'', type:'ok' },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const { data } = await axios.get('/app/api/smtp-config');
|
||||||
|
if (data.ok && data.config) {
|
||||||
|
this.form = {
|
||||||
|
host: data.config.host || '',
|
||||||
|
port: data.config.port || 587,
|
||||||
|
username: data.config.username || '',
|
||||||
|
password: '***',
|
||||||
|
encryption: data.config.encryption || 'tls',
|
||||||
|
from_name: data.config.from_name || '',
|
||||||
|
from_address: data.config.from_address || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.post('/app/api/smtp-config', this.form);
|
||||||
|
this.showToast('Configuración guardada correctamente');
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error al guardar', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async testSMTP() {
|
||||||
|
if (!this.testEmail) { this.showToast('Ingresa un email de prueba', 'error'); return; }
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await axios.post('/app/api/smtp-config/test', { email: this.testEmail });
|
||||||
|
this.showToast('Correo de prueba enviado a ' + this.testEmail);
|
||||||
|
} catch(e) { this.showToast(e.response?.data?.error||'Error SMTP', 'error'); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
showToast(msg, type='ok') {
|
||||||
|
this.toast = { show:true, msg, type };
|
||||||
|
setTimeout(() => this.toast.show = false, 3500);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClientesView(c *fiber.Ctx) error {
|
||||||
|
return c.Render("renovaciones/clientes", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClientes(c *fiber.Ctx) error {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit", "10"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
records, total, err := models.GetAllClientes(limit, offset, c.Query("search"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"registros": records,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClientesSelect(c *fiber.Ctx) error {
|
||||||
|
records, err := models.GetAllClientesSelect()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCliente(c *fiber.Ctx) error {
|
||||||
|
var m models.Cliente
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
m.Activo = true
|
||||||
|
if err := models.CreateCliente(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(201).JSON(fiber.Map{"message": "Cliente creado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCliente(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"})
|
||||||
|
}
|
||||||
|
var m models.Cliente
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
m.ID = uint(id)
|
||||||
|
if err := models.UpdateCliente(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCliente(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"})
|
||||||
|
}
|
||||||
|
if err := models.DeleteCliente(uint(id)); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ContratosView(c *fiber.Ctx) error {
|
||||||
|
return c.Render("renovaciones/contratos", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetContratos(c *fiber.Ctx) error {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit", "10"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
records, total, err := models.GetAllContratos(limit, offset, c.Query("search"), c.Query("estado"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
// Enriquecer con días restantes
|
||||||
|
type ContratoDTO struct {
|
||||||
|
models.Contrato
|
||||||
|
DiasRestantes int `json:"dias_restantes"`
|
||||||
|
Urgencia string `json:"urgencia"` // verde | amarillo | rojo
|
||||||
|
}
|
||||||
|
dtos := make([]ContratoDTO, len(records))
|
||||||
|
now := time.Now()
|
||||||
|
for i, r := range records {
|
||||||
|
dias := int(r.FechaVencimiento.Sub(now).Hours() / 24)
|
||||||
|
urgencia := "verde"
|
||||||
|
if dias <= 0 {
|
||||||
|
urgencia = "rojo"
|
||||||
|
} else if dias <= 30 {
|
||||||
|
urgencia = "amarillo"
|
||||||
|
}
|
||||||
|
dtos[i] = ContratoDTO{Contrato: r, DiasRestantes: dias, Urgencia: urgencia}
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"registros": dtos,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateContrato(c *fiber.Ctx) error {
|
||||||
|
type Input struct {
|
||||||
|
ClienteID uint `json:"cliente_id" form:"cliente_id"`
|
||||||
|
ServicioID uint `json:"servicio_id" form:"servicio_id"`
|
||||||
|
FechaInicio string `json:"fecha_inicio" form:"fecha_inicio"`
|
||||||
|
FechaVencimiento string `json:"fecha_vencimiento" form:"fecha_vencimiento"`
|
||||||
|
PrecioAcordado float64 `json:"precio_acordado" form:"precio_acordado"`
|
||||||
|
AutoRenovar bool `json:"auto_renovar" form:"auto_renovar"`
|
||||||
|
Notas string `json:"notas" form:"notas"`
|
||||||
|
}
|
||||||
|
var inp Input
|
||||||
|
if err := c.BodyParser(&inp); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
fi, err := time.Parse("2006-01-02", inp.FechaInicio)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Fecha inicio inválida"})
|
||||||
|
}
|
||||||
|
fv, err := time.Parse("2006-01-02", inp.FechaVencimiento)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Fecha vencimiento inválida"})
|
||||||
|
}
|
||||||
|
m := models.Contrato{
|
||||||
|
ClienteID: inp.ClienteID,
|
||||||
|
ServicioID: inp.ServicioID,
|
||||||
|
FechaInicio: fi,
|
||||||
|
FechaVencimiento: fv,
|
||||||
|
PrecioAcordado: inp.PrecioAcordado,
|
||||||
|
AutoRenovar: inp.AutoRenovar,
|
||||||
|
Notas: inp.Notas,
|
||||||
|
Estado: "activo",
|
||||||
|
}
|
||||||
|
if err := models.CreateContrato(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(201).JSON(fiber.Map{"message": "Contrato creado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateContrato(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"})
|
||||||
|
}
|
||||||
|
type Input struct {
|
||||||
|
Estado string `json:"estado" form:"estado"`
|
||||||
|
FechaVencimiento string `json:"fecha_vencimiento" form:"fecha_vencimiento"`
|
||||||
|
PrecioAcordado float64 `json:"precio_acordado" form:"precio_acordado"`
|
||||||
|
AutoRenovar bool `json:"auto_renovar" form:"auto_renovar"`
|
||||||
|
Notas string `json:"notas" form:"notas"`
|
||||||
|
}
|
||||||
|
var inp Input
|
||||||
|
if err := c.BodyParser(&inp); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
existing, err := models.GetContratoByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||||
|
}
|
||||||
|
if inp.FechaVencimiento != "" {
|
||||||
|
fv, err := time.Parse("2006-01-02", inp.FechaVencimiento)
|
||||||
|
if err == nil {
|
||||||
|
existing.FechaVencimiento = fv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inp.Estado != "" {
|
||||||
|
existing.Estado = inp.Estado
|
||||||
|
}
|
||||||
|
existing.PrecioAcordado = inp.PrecioAcordado
|
||||||
|
existing.AutoRenovar = inp.AutoRenovar
|
||||||
|
existing.Notas = inp.Notas
|
||||||
|
if err := models.UpdateContrato(*existing); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenovarContrato(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"})
|
||||||
|
}
|
||||||
|
existing, err := models.GetContratoByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||||
|
}
|
||||||
|
// Calcular nueva fecha según periodicidad del servicio
|
||||||
|
nuevaInicio := existing.FechaVencimiento.AddDate(0, 0, 1)
|
||||||
|
nuevaVenc := calcularFechaVencimiento(nuevaInicio, existing.Servicio.Periodicidad)
|
||||||
|
|
||||||
|
nuevo := models.Contrato{
|
||||||
|
ClienteID: existing.ClienteID,
|
||||||
|
ServicioID: existing.ServicioID,
|
||||||
|
FechaInicio: nuevaInicio,
|
||||||
|
FechaVencimiento: nuevaVenc,
|
||||||
|
PrecioAcordado: existing.PrecioAcordado,
|
||||||
|
AutoRenovar: existing.AutoRenovar,
|
||||||
|
Estado: "activo",
|
||||||
|
Notas: "Renovación automática desde contrato #" + strconv.Itoa(int(existing.ID)),
|
||||||
|
}
|
||||||
|
if err := models.CreateContrato(nuevo); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
// Marcar anterior como renovado
|
||||||
|
existing.Estado = "renovado"
|
||||||
|
models.UpdateContrato(*existing)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"message": "Renovado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnviarCorreoContrato(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"})
|
||||||
|
}
|
||||||
|
if err := services.EnviarCorreoManual(contrato); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteContrato(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"})
|
||||||
|
}
|
||||||
|
if err := models.DeleteContrato(uint(id)); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcularFechaVencimiento(desde time.Time, periodicidad string) time.Time {
|
||||||
|
switch periodicidad {
|
||||||
|
case "mensual":
|
||||||
|
return desde.AddDate(0, 1, 0)
|
||||||
|
case "trimestral":
|
||||||
|
return desde.AddDate(0, 3, 0)
|
||||||
|
case "semestral":
|
||||||
|
return desde.AddDate(0, 6, 0)
|
||||||
|
case "anual":
|
||||||
|
return desde.AddDate(1, 0, 0)
|
||||||
|
default:
|
||||||
|
return desde.AddDate(1, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Plantillas ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func PlantillasView(c *fiber.Ctx) error {
|
||||||
|
return c.Render("renovaciones/plantillas", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPlantillas(c *fiber.Ctx) error {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit", "10"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
records, total, err := models.GetAllPlantillas(limit, offset, c.Query("search"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"registros": records,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||||
|
"page": page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePlantilla(c *fiber.Ctx) error {
|
||||||
|
var m models.PlantillaCorreo
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if err := models.CreatePlantilla(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(201).JSON(fiber.Map{"message": "Plantilla creada", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePlantilla(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"})
|
||||||
|
}
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.BodyParser(&updates); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if err := models.UpdatePlantilla(uint(id), updates); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePlantilla(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"})
|
||||||
|
}
|
||||||
|
if err := models.DeletePlantilla(uint(id)); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PreviewPlantilla(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"})
|
||||||
|
}
|
||||||
|
p, err := models.GetPlantillaByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrada"})
|
||||||
|
}
|
||||||
|
html, err := services.RenderPlantilla(p, services.DatosEjemplo())
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"html": html, "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvioPlantilla(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"})
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&body); err != nil || body.Email == "" {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Email requerido"})
|
||||||
|
}
|
||||||
|
p, err := models.GetPlantillaByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrada"})
|
||||||
|
}
|
||||||
|
if err := services.EnviarCorreoPrueba(body.Email, p); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reglas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func ReglasView(c *fiber.Ctx) error {
|
||||||
|
return c.Render("renovaciones/reglas", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetReglas(c *fiber.Ctx) error {
|
||||||
|
records, err := models.GetAllReglas()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"registros": records, "total": len(records)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRegla(c *fiber.Ctx) error {
|
||||||
|
var m models.NotificacionRegla
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if err := models.CreateRegla(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(201).JSON(fiber.Map{"message": "Regla creada", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateRegla(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"})
|
||||||
|
}
|
||||||
|
var m models.NotificacionRegla
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
m.ID = uint(id)
|
||||||
|
if err := models.UpdateRegla(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteRegla(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"})
|
||||||
|
}
|
||||||
|
if err := models.DeleteRegla(uint(id)); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Historial ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func HistorialView(c *fiber.Ctx) error {
|
||||||
|
return c.Render("renovaciones/historial", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHistorial(c *fiber.Ctx) error {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
records, total, err := models.GetAllLogs(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"registros": records,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||||
|
"page": page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReenviarNotificacion(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"})
|
||||||
|
}
|
||||||
|
log, err := models.GetLogByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||||
|
}
|
||||||
|
if err := services.ReenviarLog(log); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Reenviado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerPreviewLog(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"})
|
||||||
|
}
|
||||||
|
log, err := models.GetLogByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"html": log.PreviewHTML, "asunto": log.Asunto, "ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ServiciosView(c *fiber.Ctx) error {
|
||||||
|
return c.Render("renovaciones/servicios", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetServicios(c *fiber.Ctx) error {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit", "10"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
records, total, err := models.GetAllServicios(limit, offset, c.Query("search"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"registros": records,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetServiciosSelect(c *fiber.Ctx) error {
|
||||||
|
records, err := models.GetAllServiciosSelect()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateServicio(c *fiber.Ctx) error {
|
||||||
|
var m models.Servicio
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
m.Activo = true
|
||||||
|
if err := models.CreateServicio(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(201).JSON(fiber.Map{"message": "Servicio creado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateServicio(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"})
|
||||||
|
}
|
||||||
|
var m models.Servicio
|
||||||
|
if err := c.BodyParser(&m); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
m.ID = uint(id)
|
||||||
|
if err := models.UpdateServicio(m); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Actualizado", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteServicio(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"})
|
||||||
|
}
|
||||||
|
if err := models.DeleteServicio(uint(id)); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SmtpConfigView(c *fiber.Ctx) error {
|
||||||
|
cfg, _ := models.GetSmtpConfig()
|
||||||
|
return c.Render("renovaciones/smtp", fiber.Map{
|
||||||
|
"user": c.Locals("user"),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
"config": cfg,
|
||||||
|
}, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSmtpConfig(c *fiber.Ctx) error {
|
||||||
|
cfg, err := models.GetSmtpConfig()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{"ok": false, "config": nil})
|
||||||
|
}
|
||||||
|
// No exponer contraseña en claro
|
||||||
|
cfg.Password = "***"
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "config": cfg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveSmtpConfig(c *fiber.Ctx) error {
|
||||||
|
type Input struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Encryption string `json:"encryption"`
|
||||||
|
FromAddress string `json:"from_address"`
|
||||||
|
FromName string `json:"from_name"`
|
||||||
|
}
|
||||||
|
var inp Input
|
||||||
|
if err := c.BodyParser(&inp); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cifrar contraseña solo si se provee una nueva (no es placeholder)
|
||||||
|
passEncrypted := inp.Password
|
||||||
|
if inp.Password != "" && inp.Password != "***" {
|
||||||
|
passEncrypted = utils.Encrypt(inp.Password, app.Http.Server.Key)
|
||||||
|
} else if inp.Password == "***" {
|
||||||
|
// Mantener contraseña ya guardada
|
||||||
|
if existing, err := models.GetSmtpConfig(); err == nil {
|
||||||
|
passEncrypted = existing.Password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := models.SmtpConfig{
|
||||||
|
Host: inp.Host,
|
||||||
|
Port: inp.Port,
|
||||||
|
Username: inp.Username,
|
||||||
|
Password: passEncrypted,
|
||||||
|
Encryption: inp.Encryption,
|
||||||
|
FromAddress: inp.FromAddress,
|
||||||
|
FromName: inp.FromName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.SaveSmtpConfig(cfg); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recargar el mailer en memoria con la nueva config
|
||||||
|
app.Http.Mail.Host = cfg.Host
|
||||||
|
app.Http.Mail.Port = cfg.Port
|
||||||
|
app.Http.Mail.Username = cfg.Username
|
||||||
|
app.Http.Mail.Password = utils.Decrypt(passEncrypted, app.Http.Server.Key)
|
||||||
|
app.Http.Mail.Encryption = cfg.Encryption
|
||||||
|
app.Http.Mail.FromAddress = cfg.FromAddress
|
||||||
|
app.Http.Mail.FromName = cfg.FromName
|
||||||
|
app.Http.Mail.SetupMailer()
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"message": "Configuración guardada", "ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpConfig(c *fiber.Ctx) error {
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&body); err != nil || body.Email == "" {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Email de destino requerido"})
|
||||||
|
}
|
||||||
|
htmlBody := "<h2>Test SMTP</h2><p>La configuración SMTP funciona correctamente.</p>"
|
||||||
|
if err := app.Http.Mail.Send(body.Email, "Test SMTP - U-site", htmlBody); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "Correo de prueba enviado", "ok": true})
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package middlewares
|
package middlewares
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gookit/validate"
|
"github.com/gookit/validate"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
"net/url"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func RedirectToHomePageOnLogin(c *fiber.Ctx) error {
|
func RedirectToHomePageOnLogin(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenovacionesRoutes registra todas las rutas del módulo de renovaciones/contratos
|
||||||
|
func RenovacionesRoutes(protected fiber.Router) {
|
||||||
|
// ─── Servicios ───────────────────────────────────────────────────
|
||||||
|
protected.Get("/servicios", middlewares.MenuMiddleware, controllers.ServiciosView)
|
||||||
|
protected.Get("/api/servicios", controllers.GetServicios)
|
||||||
|
protected.Get("/api/servicios/select", controllers.GetServiciosSelect)
|
||||||
|
protected.Post("/api/servicios", controllers.CreateServicio)
|
||||||
|
protected.Put("/api/servicios/:id", controllers.UpdateServicio)
|
||||||
|
protected.Delete("/api/servicios/:id", controllers.DeleteServicio)
|
||||||
|
|
||||||
|
// ─── Clientes ────────────────────────────────────────────────────
|
||||||
|
protected.Get("/clientes", middlewares.MenuMiddleware, controllers.ClientesView)
|
||||||
|
protected.Get("/api/clientes", controllers.GetClientes)
|
||||||
|
protected.Get("/api/clientes/select", controllers.GetClientesSelect)
|
||||||
|
protected.Post("/api/clientes", controllers.CreateCliente)
|
||||||
|
protected.Put("/api/clientes/:id", controllers.UpdateCliente)
|
||||||
|
protected.Delete("/api/clientes/:id", controllers.DeleteCliente)
|
||||||
|
|
||||||
|
// ─── Contratos ───────────────────────────────────────────────────
|
||||||
|
protected.Get("/contratos", middlewares.MenuMiddleware, controllers.ContratosView)
|
||||||
|
protected.Get("/api/contratos", controllers.GetContratos)
|
||||||
|
protected.Post("/api/contratos", controllers.CreateContrato)
|
||||||
|
protected.Put("/api/contratos/:id", controllers.UpdateContrato)
|
||||||
|
protected.Delete("/api/contratos/:id", controllers.DeleteContrato)
|
||||||
|
protected.Post("/api/contratos/:id/renovar", controllers.RenovarContrato)
|
||||||
|
protected.Post("/api/contratos/:id/enviar-correo", controllers.EnviarCorreoContrato)
|
||||||
|
|
||||||
|
// ─── Plantillas de correo ─────────────────────────────────────────
|
||||||
|
protected.Get("/plantillas-correo", middlewares.MenuMiddleware, controllers.PlantillasView)
|
||||||
|
protected.Get("/api/plantillas-correo", controllers.GetPlantillas)
|
||||||
|
protected.Post("/api/plantillas-correo", controllers.CreatePlantilla)
|
||||||
|
protected.Put("/api/plantillas-correo/:id", controllers.UpdatePlantilla)
|
||||||
|
protected.Delete("/api/plantillas-correo/:id", controllers.DeletePlantilla)
|
||||||
|
protected.Get("/api/plantillas-correo/:id/preview", controllers.PreviewPlantilla)
|
||||||
|
protected.Post("/api/plantillas-correo/:id/test", controllers.TestEnvioPlantilla)
|
||||||
|
|
||||||
|
// ─── Reglas de notificación ───────────────────────────────────────
|
||||||
|
protected.Get("/reglas-notificacion", middlewares.MenuMiddleware, controllers.ReglasView)
|
||||||
|
protected.Get("/api/reglas-notificacion", controllers.GetReglas)
|
||||||
|
protected.Post("/api/reglas-notificacion", controllers.CreateRegla)
|
||||||
|
protected.Put("/api/reglas-notificacion/:id", controllers.UpdateRegla)
|
||||||
|
protected.Delete("/api/reglas-notificacion/:id", controllers.DeleteRegla)
|
||||||
|
|
||||||
|
// ─── Historial de notificaciones ─────────────────────────────────
|
||||||
|
protected.Get("/historial-notificaciones", middlewares.MenuMiddleware, controllers.HistorialView)
|
||||||
|
protected.Get("/api/historial-notificaciones", controllers.GetHistorial)
|
||||||
|
protected.Post("/api/historial-notificaciones/:id/reenviar", controllers.ReenviarNotificacion)
|
||||||
|
protected.Get("/api/historial-notificaciones/:id/preview", controllers.VerPreviewLog)
|
||||||
|
|
||||||
|
// ─── Configuración SMTP ───────────────────────────────────────────
|
||||||
|
protected.Get("/smtp-config", middlewares.MenuMiddleware, controllers.SmtpConfigView)
|
||||||
|
protected.Get("/api/smtp-config", controllers.GetSmtpConfig)
|
||||||
|
protected.Post("/api/smtp-config", controllers.SaveSmtpConfig)
|
||||||
|
protected.Post("/api/smtp-config/test", controllers.TestSmtpConfig)
|
||||||
|
}
|
||||||
+2
-2
@@ -96,6 +96,6 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Put("/tipodb/:id", controllers.UpdateTipoDb)
|
protected.Put("/tipodb/:id", controllers.UpdateTipoDb)
|
||||||
protected.Delete("/tipodb/:id", controllers.DeleteTipoDb)
|
protected.Delete("/tipodb/:id", controllers.DeleteTipoDb)
|
||||||
|
|
||||||
|
// ─── Módulo de Renovaciones / Contratos ──────────────────────────
|
||||||
|
RenovacionesRoutes(protected)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user