This commit is contained in:
Lizandro Guarnizo
2026-04-29 23:36:30 -05:00
parent 9d4c4dba65
commit f787821444
30 changed files with 3738 additions and 8 deletions
+681
View File
@@ -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*
+1
View File
@@ -111,6 +111,7 @@ require (
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/stretchr/testify v1.10.0 // indirect
+2
View File
@@ -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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+4
View File
@@ -8,6 +8,7 @@ import (
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/migrations"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
)
@@ -41,6 +42,9 @@ func main() {
// Ejecutar migraciones
migrations.Migrate()
} else {
// Iniciar cron de vencimientos
services.IniciarCron()
defer services.DetenerCron()
// Cargar rutas
routes.LoadRoutes(app.Http.Server.App)
app.Http.Route404()
+8
View File
@@ -42,6 +42,14 @@ func Migrate() {
&models.OssApi{},
&models.VcfVcard{},
&models.DlocalApi{},
// Módulo de Renovaciones
&models.Servicio{},
&models.Cliente{},
&models.PlantillaCorreo{},
&models.NotificacionRegla{},
&models.Contrato{},
&models.NotificacionLog{},
&models.SmtpConfig{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
}
+75
View File
@@ -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
}
+98
View File
@@ -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
}
+87
View File
@@ -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)
}
+65
View File
@@ -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
}
+64
View File
@@ -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
}
+73
View File
@@ -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
}
+48
View File
@@ -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
}
+98
View File
@@ -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(&regla, 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")
}
+204
View File
@@ -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
}
+4 -5
View File
@@ -77,12 +77,11 @@
</div>
<!-- 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"
style="background-color:#8eb02f"
:style="loading ? 'opacity:0.7; cursor:not-allowed' : ''"
onmouseover="if(!this.disabled) this.style.backgroundColor='#6d8c24'"
onmouseout="if(!this.disabled) this.style.backgroundColor='#8eb02f'">
:class="loading ? 'opacity-70 cursor-not-allowed' : 'hover:opacity-90'"
style="background-color:#8eb02f">
<span x-show="!loading" class="flex items-center justify-center gap-2">
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">
+207
View File
@@ -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>
+290
View File
@@ -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>
+143
View File
@@ -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>&#123;&#123;.ClienteNombre&#125;&#125;</code>,
<code>&#123;&#123;.ClienteEmpresa&#125;&#125;</code>,
<code>&#123;&#123;.FechaVencimiento&#125;&#125;</code>,
<code>&#123;&#123;.DiasRestantes&#125;&#125;</code>,
<code>&#123;&#123;.Total&#125;&#125;</code>,
<code>&#123;&#123;range .Servicios&#125;&#125;...&#123;&#123;.Nombre&#125;&#125; &#123;&#123;.Precio&#125;&#125;...&#123;&#123;end&#125;&#125;</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>
+202
View File
@@ -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>
+227
View File
@@ -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>
+133
View File
@@ -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>
+83
View File
@@ -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})
}
+206
View File
@@ -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)
}
}
+222
View File
@@ -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})
}
+83
View File
@@ -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})
}
+2 -1
View File
@@ -1,11 +1,12 @@
package middlewares
import (
"net/url"
"github.com/gofiber/fiber/v2"
"github.com/gookit/validate"
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"net/url"
)
func RedirectToHomePageOnLogin(c *fiber.Ctx) error {
+63
View File
@@ -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
View File
@@ -96,6 +96,6 @@ func UserRoutes(app fiber.Router) {
protected.Put("/tipodb/:id", controllers.UpdateTipoDb)
protected.Delete("/tipodb/:id", controllers.DeleteTipoDb)
// ─── Módulo de Renovaciones / Contratos ──────────────────────────
RenovacionesRoutes(protected)
}