This commit is contained in:
Lizandro Guarnizo
2026-06-04 22:49:16 -05:00
parent 72744a1325
commit 7329bac898
8 changed files with 176 additions and 18 deletions
+8
View File
@@ -95,6 +95,14 @@ func JoinModulos(modules []string) string {
return strings.Join(clean, ",")
}
func GetAiConfigSelect() ([]AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Model(&AiConfig{}).Select("id, nombre, provider").Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
// Lógica de prioridad:
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
+28 -4
View File
@@ -20,6 +20,13 @@ type Contrato struct {
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"`
// Infraestructura asociada (opcional)
ServidorID *uint `json:"servidor_id" gorm:"column:servidor_id"`
Servidor Servidor `json:"servidor" gorm:"foreignKey:ServidorID"`
ConxDbID *uint `json:"conx_db_id" gorm:"column:conx_db_id"`
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
AiConfig AiConfig `json:"ai_config" gorm:"foreignKey:AiConfigID"`
// Enlace de pago Bold: único por ciclo de pago.
// Se reutiliza en múltiples notificaciones del mismo ciclo.
// Se anula (vacía) cuando SALE_APPROVED llega y se registra el pago.
@@ -36,7 +43,8 @@ func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int6
var items []Contrato
var total int64
db := app.Http.Database.DB.Model(&Contrato{}).
Preload("Cliente").Preload("Servicios")
Preload("Cliente").Preload("Servicios").
Preload("Servidor").Preload("ConxDb").Preload("AiConfig")
if search != "" {
db = db.Joins("JOIN clientes ON clientes.id = contratos.cliente_id").
Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?",
@@ -56,7 +64,7 @@ func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int6
func GetContratoByID(id uint) (*Contrato, error) {
var item Contrato
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").First(&item, id).Error; err != nil {
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").Preload("Servidor").Preload("ConxDb").Preload("AiConfig").First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
@@ -156,7 +164,7 @@ func CreateContrato(c Contrato, servicioIDs []uint) error {
func UpdateContrato(c Contrato, servicioIDs []uint) error {
db := app.Http.Database.DB
if err := db.Model(&Contrato{}).Where("id = ?", c.ID).Updates(map[string]interface{}{
updates := map[string]interface{}{
"cliente_id": c.ClienteID,
"fecha_inicio": c.FechaInicio,
"fecha_vencimiento": c.FechaVencimiento,
@@ -165,7 +173,23 @@ func UpdateContrato(c Contrato, servicioIDs []uint) error {
"estado": c.Estado,
"auto_renovar": c.AutoRenovar,
"notas": c.Notas,
}).Error; err != nil {
}
if c.ServidorID != nil {
updates["servidor_id"] = *c.ServidorID
} else {
updates["servidor_id"] = nil
}
if c.ConxDbID != nil {
updates["conx_db_id"] = *c.ConxDbID
} else {
updates["conx_db_id"] = nil
}
if c.AiConfigID != nil {
updates["ai_config_id"] = *c.AiConfigID
} else {
updates["ai_config_id"] = nil
}
if err := db.Model(&Contrato{}).Where("id = ?", c.ID).Updates(updates).Error; err != nil {
return err
}
if servicioIDs != nil {
+12
View File
@@ -88,3 +88,15 @@ func DeleteConxDb(conxDb ConxDb) error {
}
return nil
}
func GetConxDbSelect(servidorID string) ([]ConxDb, error) {
var items []ConxDb
db := app.Http.Database.DB.Model(&ConxDb{}).Select("id, nombre, servidor_id").Order("nombre ASC")
if servidorID != "" {
db = db.Where("servidor_id = ?", servidorID)
}
if err := db.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
+88 -6
View File
@@ -29,6 +29,7 @@
<tr>
<th class="py-2 px-3">Cliente</th>
<th class="py-2 px-3">Servicios</th>
<th class="py-2 px-3">Infra</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>
@@ -49,6 +50,29 @@
</template>
<span x-show="!d.servicios||d.servicios.length===0" class="text-gray-400"></span>
</td>
<td class="py-2 px-3">
<div class="flex gap-1 flex-wrap">
<template x-if="d.servidor">
<span class="inline-flex items-center gap-0.5 text-xs bg-blue-50 text-blue-700 rounded px-1.5 py-0.5" title="Servidor: " + d.servidor.nombre>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.75L4.5 7.5l.75-1.5m-2.25 6.75h16.5"/></svg>
<span x-text="d.servidor.nombre"></span>
</span>
</template>
<template x-if="d.conx_db">
<span class="inline-flex items-center gap-0.5 text-xs bg-green-50 text-green-700 rounded px-1.5 py-0.5" title="BD: " + d.conx_db.nombre>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"/></svg>
<span x-text="d.conx_db.nombre"></span>
</span>
</template>
<template x-if="d.ai_config">
<span class="inline-flex items-center gap-0.5 text-xs bg-purple-50 text-purple-700 rounded px-1.5 py-0.5" title="IA: " + d.ai_config.nombre>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"/></svg>
<span x-text="d.ai_config.nombre"></span>
</span>
</template>
<span x-show="!d.servidor && !d.conx_db && !d.ai_config" class="text-gray-300"></span>
</div>
</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"
@@ -103,7 +127,7 @@
</tr>
</template>
<tr x-show="!loading && datos.length===0">
<td colspan="7" class="text-center text-gray-400 py-8">Sin registros</td>
<td colspan="8" class="text-center text-gray-400 py-8">Sin registros</td>
</tr>
</tbody>
</table>
@@ -186,6 +210,37 @@
<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="col-span-2 border-t border-gray-100 pt-3 mt-1">
<p class="text-xs font-semibold text-gray-500 mb-2 uppercase tracking-wider">Infraestructura <span class="font-normal normal-case text-gray-400">(opcional)</span></p>
</div>
<div class="col-span-2 sm:col-span-1">
<label class="text-xs font-medium text-gray-600">Servidor</label>
<select x-model="form.servidor_id" @change="onServidorChange()" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="">— Sin servidor —</option>
<template x-for="s in servidoresList" :key="s.ID">
<option :value="s.ID" x-text="s.nombre + ' — ' + (s.ip_servidor||'')"></option>
</template>
</select>
</div>
<div class="col-span-2 sm:col-span-1">
<label class="text-xs font-medium text-gray-600">Base de datos</label>
<select x-model="form.conx_db_id" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="">— Sin BD —</option>
<template x-for="db in conxDbList" :key="db.ID">
<option :value="db.ID" x-text="db.nombre"></option>
</template>
</select>
<p x-show="form.servidor_id && conxDbList.length===0" class="text-xs text-gray-400 mt-0.5">Sin BD para este servidor</p>
</div>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">IA / AI Config</label>
<select x-model="form.ai_config_id" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="">— Sin IA —</option>
<template x-for="ai in aiConfigList" :key="ai.ID">
<option :value="ai.ID" x-text="ai.nombre + ' (' + ai.provider + ')'"></option>
</template>
</select>
</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>
@@ -349,9 +404,10 @@ document.addEventListener('alpine:init', () => {
bienvenidaModal: false, bienvenidaReglaId: '', bienvenidaContratoId: null,
historialModal: false, historialTitulo: '', historialTimeline: [], historialLoading: false,
clientes: [], servicios: [], reglas: [], reglasBienvenida: [],
servidoresList: [], conxDbList: [], aiConfigList: [],
selectedId: null,
verificandoID: null,
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, moneda:'COP', estado:'activo', auto_renovar:false, notas:'' },
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, moneda:'COP', estado:'activo', auto_renovar:false, notas:'', servidor_id:'', conx_db_id:'', ai_config_id:'' },
toast: { show: false, msg: '', type: 'ok' },
async init() {
@@ -369,10 +425,12 @@ document.addEventListener('alpine:init', () => {
async loadSelects() {
try {
const [c, s, r] = await Promise.all([
const [c, s, r, sv, ai] = await Promise.all([
axios.get('/app/api/clientes/select'),
axios.get('/app/api/servicios/select'),
axios.get('/app/api/reglas-notificacion'),
axios.get('/app/loadservidorselect'),
axios.get('/app/api/ai-config/select'),
]);
this.clientes = Array.isArray(c.data) ? c.data : (c.data.registros || []);
this.servicios = Array.isArray(s.data) ? s.data : (s.data.registros || []);
@@ -380,11 +438,24 @@ document.addEventListener('alpine:init', () => {
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;
this.servidoresList = sv.data.registros || [];
this.aiConfigList = ai.data.registros || [];
} catch(e) {
this.showToast('Error cargando listas: ' + (e.response?.data?.error || e.message), 'error');
}
},
async onServidorChange() {
this.form.conx_db_id = '';
if (!this.form.servidor_id) { this.conxDbList = []; return; }
try {
const res = await axios.get('/app/api/conxdb/select', { params: { servidor_id: this.form.servidor_id } });
this.conxDbList = res.data.registros || [];
} catch(e) {
this.conxDbList = [];
}
},
get precioSugerido() {
return this.servicios
.filter(s => (this.form.servicio_ids || []).includes(s.ID))
@@ -400,7 +471,7 @@ document.addEventListener('alpine:init', () => {
this.form.precio_acordado = this.precioSugerido;
},
openEdit(d) {
async openEdit(d) {
this.form = {
cliente_id: d.cliente_id,
servicio_ids: (d.servicios || []).map(s => s.ID),
@@ -410,16 +481,24 @@ document.addEventListener('alpine:init', () => {
moneda: d.moneda || 'COP',
estado: d.estado,
auto_renovar: d.auto_renovar,
notas: d.notas
notas: d.notas,
servidor_id: d.servidor_id || '',
conx_db_id: d.conx_db_id || '',
ai_config_id: d.ai_config_id || ''
};
this.selectedId = d.ID;
this.editModal = true;
// Cargar BD del servidor seleccionado
if (d.servidor_id) {
await this.onServidorChange();
}
},
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_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, moneda:'COP', estado:'activo', auto_renovar:false, notas:'' };
this.conxDbList = [];
this.form = { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, moneda:'COP', estado:'activo', auto_renovar:false, notas:'', servidor_id:'', conx_db_id:'', ai_config_id:'' };
},
async save() {
@@ -430,6 +509,9 @@ document.addEventListener('alpine:init', () => {
cliente_id: parseInt(this.form.cliente_id) || 0,
servicio_id: parseInt(this.form.servicio_id) || 0,
precio_acordado: parseFloat(this.form.precio_acordado) || 0,
servidor_id: this.form.servidor_id ? parseInt(this.form.servidor_id) : null,
conx_db_id: this.form.conx_db_id ? parseInt(this.form.conx_db_id) : null,
ai_config_id: this.form.ai_config_id ? parseInt(this.form.ai_config_id) : null,
};
if (this.editModal) {
await axios.put(`/app/api/contratos/${this.selectedId}`, payload);
+9
View File
@@ -155,6 +155,15 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
}
// GetAiConfigSelect devuelve lista simple para selects
func GetAiConfigSelect(c *fiber.Ctx) error {
items, err := models.GetAiConfigSelect()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"registros": items})
}
// DeleteAiConfigHandler elimina una configuración de IA.
func DeleteAiConfigHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
+20 -8
View File
@@ -62,13 +62,16 @@ func GetContratos(c *fiber.Ctx) error {
func CreateContrato(c *fiber.Ctx) error {
type Input struct {
ClienteID uint `json:"cliente_id"`
ServicioIDs []uint `json:"servicio_ids"`
FechaInicio string `json:"fecha_inicio"`
FechaVencimiento string `json:"fecha_vencimiento"`
ClienteID uint `json:"cliente_id"`
ServicioIDs []uint `json:"servicio_ids"`
FechaInicio string `json:"fecha_inicio"`
FechaVencimiento string `json:"fecha_vencimiento"`
PrecioAcordado float64 `json:"precio_acordado"`
AutoRenovar bool `json:"auto_renovar"`
Notas string `json:"notas"`
AutoRenovar bool `json:"auto_renovar"`
Notas string `json:"notas"`
ServidorID *uint `json:"servidor_id"`
ConxDbID *uint `json:"conx_db_id"`
AiConfigID *uint `json:"ai_config_id"`
}
var inp Input
if err := c.BodyParser(&inp); err != nil {
@@ -86,10 +89,13 @@ func CreateContrato(c *fiber.Ctx) error {
ClienteID: inp.ClienteID,
FechaInicio: fi,
FechaVencimiento: fv,
PrecioAcordado: inp.PrecioAcordado,
PrecioAcordado: float64(inp.PrecioAcordado),
AutoRenovar: inp.AutoRenovar,
Notas: inp.Notas,
Estado: "activo",
ServidorID: inp.ServidorID,
ConxDbID: inp.ConxDbID,
AiConfigID: inp.AiConfigID,
}
if err := models.CreateContrato(m, inp.ServicioIDs); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
@@ -115,10 +121,13 @@ func UpdateContrato(c *fiber.Ctx) error {
PrecioAcordado float64 `json:"precio_acordado"`
AutoRenovar bool `json:"auto_renovar"`
Notas string `json:"notas"`
ServidorID *uint `json:"servidor_id"`
ConxDbID *uint `json:"conx_db_id"`
AiConfigID *uint `json:"ai_config_id"`
}
var inp Input
if err := c.BodyParser(&inp); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
existing, err := models.GetContratoByID(uint(id))
if err != nil {
@@ -136,6 +145,9 @@ func UpdateContrato(c *fiber.Ctx) error {
existing.PrecioAcordado = inp.PrecioAcordado
existing.AutoRenovar = inp.AutoRenovar
existing.Notas = inp.Notas
existing.ServidorID = inp.ServidorID
existing.ConxDbID = inp.ConxDbID
existing.AiConfigID = inp.AiConfigID
if err := models.UpdateContrato(*existing, inp.ServicioIDs); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
+9
View File
@@ -125,3 +125,12 @@ func DeleteConxDb(c *fiber.Ctx) error {
"message": "conx ssh eliminado exitosamente",
})
}
func GetConxDbSelect(c *fiber.Ctx) error {
servidorID := c.Query("servidor_id")
items, err := models.GetConxDbSelect(servidorID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"registros": items})
}
+2
View File
@@ -65,6 +65,7 @@ func UserRoutes(app fiber.Router) {
// Rutas de conexiones db
protected.Get("/conexion_db", middlewares.MenuMiddleware, controllers.ConxDb) // Renderizar la vista
protected.Get("/loadconexiondb", controllers.GetConxDb) // Obtener
protected.Get("/api/conxdb/select", controllers.GetConxDbSelect) // Select (filtrado por ?servidor_id=)
protected.Post("/conexiondb", controllers.CreateConxDb) // Crear
protected.Put("/conexiondb/:id", controllers.UpdateConxDb) // Actualizar
protected.Delete("/conexiondb/:id", controllers.DeleteConxDb) // Eliminar
@@ -284,6 +285,7 @@ func UserRoutes(app fiber.Router) {
// ─── Configuraciones de IA (Qwen, OpenAI, etc.) ─────────────────────────
protected.Get("/ai-config", middlewares.MenuMiddleware, controllers.AiConfigIndex)
protected.Get("/ai-config/list", controllers.GetAiConfigs)
protected.Get("/api/ai-config/select", controllers.GetAiConfigSelect)
protected.Post("/ai-config", controllers.CreateAiConfigHandler)
protected.Put("/ai-config/:id", controllers.UpdateAiConfigHandler)
protected.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler)