This commit is contained in:
Lizandro Guarnizo
2026-05-12 18:53:09 -05:00
parent 525d508982
commit 05141ce70e
3 changed files with 136 additions and 12 deletions
+11
View File
@@ -61,6 +61,17 @@ func GetContratoByID(id uint) (*Contrato, error) {
return &item, nil
}
// GetUltimoContratoByCliente devuelve el contrato más reciente de un cliente
func GetUltimoContratoByCliente(clienteID uint) (*Contrato, error) {
var item Contrato
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").
Where("cliente_id = ?", clienteID).
Order("id DESC").First(&item).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
+99 -11
View File
@@ -174,6 +174,54 @@
</div>
</div>
<!-- Modal Enviar Notificación -->
<div x-show="notifModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4 p-6" @click.stop>
<h2 class="text-lg font-semibold mb-1">Enviar notificación</h2>
<p class="text-sm text-gray-400 mb-4" x-text="notifCliente ? 'Para: '+notifCliente : ''" ></p>
<label class="text-xs font-medium text-gray-600">Selecciona la plantilla / regla *</label>
<select x-model="notifReglaId" class="mt-1 w-full border rounded px-3 py-2 text-sm mb-4">
<option value="">Seleccionar...</option>
<template x-for="r in reglas" :key="r.ID">
<option :value="r.ID" x-text="r.nombre + ' — ' + tipoLabel(r.tipo_evento)"></option>
</template>
</select>
<div class="flex justify-end gap-2">
<button @click="notifModal=false" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="confirmarEnvioNotif()" :disabled="!notifReglaId||loading"
class="px-4 py-2 bg-purple-600 text-white rounded text-sm disabled:opacity-40">
<span x-text="loading ? 'Enviando…' : 'Enviar'"></span>
</button>
</div>
</div>
</div>
<!-- Modal Bienvenida (post-creación) -->
<div x-show="bienvenidaModal" 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 text-center" @click.stop>
<div class="text-3xl mb-3">🎉</div>
<p class="font-semibold mb-1">¿Enviar correo de bienvenida?</p>
<p class="text-sm text-gray-400 mb-5">El contrato fue creado. ¿Deseas enviar el correo de bienvenida al cliente ahora?</p>
<template x-if="reglasBienvenida.length > 0">
<div class="mb-4 text-left">
<label class="text-xs font-medium text-gray-600">Plantilla de bienvenida</label>
<select x-model="bienvenidaReglaId" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<template x-for="r in reglasBienvenida" :key="r.ID">
<option :value="r.ID" x-text="r.nombre"></option>
</template>
</select>
</div>
</template>
<div class="flex justify-center gap-3">
<button @click="bienvenidaModal=false" class="px-4 py-2 border rounded text-sm">No, omitir</button>
<button @click="enviarBienvenida()" :disabled="loading"
class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm disabled:opacity-40">
<span x-text="loading ? 'Enviando…' : 'Sí, 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>
@@ -200,7 +248,9 @@ document.addEventListener('alpine:init', () => {
datos: [], total: 0, totalPages: 1, page: 1, limit: 10,
search: '', filtroEstado: '',
addModal: false, editModal: false, deleteModal: false,
clientes: [], servicios: [],
notifModal: false, notifReglaId: '', notifContratoId: null, notifCliente: '',
bienvenidaModal: false, bienvenidaReglaId: '', bienvenidaContratoId: null,
clientes: [], servicios: [], reglas: [], reglasBienvenida: [],
selectedId: null,
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' },
toast: { show: false, msg: '', type: 'ok' },
@@ -220,12 +270,17 @@ document.addEventListener('alpine:init', () => {
async loadSelects() {
try {
const [c, s] = await Promise.all([
const [c, s, r] = await Promise.all([
axios.get('/app/api/clientes/select'),
axios.get('/app/api/servicios/select'),
axios.get('/app/api/reglas-notificacion'),
]);
this.clientes = Array.isArray(c.data) ? c.data : (c.data.registros || []);
this.servicios = Array.isArray(s.data) ? s.data : (s.data.registros || []);
const todasReglas = r.data.registros || [];
this.reglas = todasReglas.filter(x => x.activo);
this.reglasBienvenida = todasReglas.filter(x => x.activo && x.tipo_evento === 'bienvenida');
if (this.reglasBienvenida.length > 0) this.bienvenidaReglaId = this.reglasBienvenida[0].ID;
} catch(e) {
this.showToast('Error cargando listas: ' + (e.response?.data?.error || e.message), 'error');
}
@@ -278,12 +333,19 @@ document.addEventListener('alpine:init', () => {
};
if (this.editModal) {
await axios.put(`/app/api/contratos/${this.selectedId}`, payload);
this.showToast('Guardado correctamente');
this.closeModals();
await this.loadData();
} else {
await axios.post('/app/api/contratos', payload);
const res = await axios.post('/app/api/contratos', payload);
this.closeModals();
await this.loadData();
// Guardar el ID del contrato recién creado para bienvenida
// La API devuelve el ID o lo obtenemos del último registro
const contratoId = res.data.id || (this.datos.length > 0 ? this.datos[0].ID : null);
this.bienvenidaContratoId = contratoId;
this.bienvenidaModal = true;
}
this.showToast('Guardado correctamente');
this.closeModals();
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
@@ -308,12 +370,38 @@ document.addEventListener('alpine:init', () => {
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
},
async enviarCorreo(d) {
if (!confirm(`¿Enviar correo de aviso a ${d.cliente?.email}?`)) return;
enviarCorreo(d) {
this.notifContratoId = d.ID;
this.notifCliente = (d.cliente?.nombre || '') + (d.cliente?.email ? ' <'+d.cliente.email+'>' : '');
this.notifReglaId = '';
this.notifModal = true;
},
async confirmarEnvioNotif() {
if (!this.notifReglaId) return;
this.loading = true;
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'); }
await axios.post(`/app/api/contratos/${this.notifContratoId}/enviar-correo`, { regla_id: parseInt(this.notifReglaId) });
this.showToast('Correo enviado correctamente');
this.notifModal = false;
} catch(e) { this.showToast(e.response?.data?.error||'Error al enviar', 'error'); }
this.loading = false;
},
async enviarBienvenida() {
this.loading = true;
try {
const payload = this.bienvenidaReglaId ? { regla_id: parseInt(this.bienvenidaReglaId) } : {};
await axios.post(`/app/api/contratos/${this.bienvenidaContratoId}/enviar-correo`, payload);
this.showToast('Correo de bienvenida enviado');
} catch(e) { this.showToast(e.response?.data?.error||'Error al enviar', 'error'); }
this.bienvenidaModal = false;
this.loading = false;
},
tipoLabel(tipo) {
const m = { vencimiento_proximo:'Próximo a vencer', ya_vencido:'Ya vencido', bienvenida:'Bienvenida', pago_recibido:'Pago recibido', manual:'Manual' };
return m[tipo] || tipo;
},
fmtDate(raw) {
+26 -1
View File
@@ -94,7 +94,13 @@ func CreateContrato(c *fiber.Ctx) error {
if err := models.CreateContrato(m, inp.ServicioIDs); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"message": "Contrato creado", "ok": true})
// Devolver el ID del contrato creado para que el frontend pueda enviar bienvenida
created, _ := models.GetUltimoContratoByCliente(inp.ClienteID)
contratoID := uint(0)
if created != nil {
contratoID = created.ID
}
return c.Status(201).JSON(fiber.Map{"message": "Contrato creado", "ok": true, "id": contratoID})
}
func UpdateContrato(c *fiber.Ctx) error {
@@ -186,6 +192,25 @@ func EnviarCorreoContrato(c *fiber.Ctx) error {
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
// Si se pasa regla_id, usar esa regla específica (EnviarNotificacionGrupo)
var body struct {
ReglaID uint `json:"regla_id"`
}
_ = c.BodyParser(&body)
if body.ReglaID > 0 {
regla, err := models.GetReglaByID(body.ReglaID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Regla no encontrada"})
}
if err := services.EnviarNotificacionGrupo(regla, &contrato.Cliente, []models.Contrato{*contrato}); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true})
}
// Sin regla_id → flujo manual genérico
if err := services.EnviarCorreoManual(contrato); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}