diff --git a/migrations/migrate.go b/migrations/migrate.go index decf63b..c91f1a5 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -67,6 +67,11 @@ func Migrate() { // Integraciones SaaS (dispatcher de pagos) &models.SaasApiConfig{}, &models.SaasDispatchLog{}, + // Documentos de clientes + &models.ClienteDocumento{}, + // Telegram + &models.TelegramConfig{}, + &models.TelegramLog{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } diff --git a/pkg/models/cliente_documento.go b/pkg/models/cliente_documento.go new file mode 100644 index 0000000..cda5b5f --- /dev/null +++ b/pkg/models/cliente_documento.go @@ -0,0 +1,47 @@ +package models + +import ( + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ClienteDocumento almacena los archivos adjuntos de un cliente. +type ClienteDocumento struct { + gorm.Model + ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;not null;index"` + Nombre string `json:"nombre" gorm:"column:nombre"` // nombre descriptivo del doc + Archivo string `json:"archivo" gorm:"column:archivo"` // ruta relativa en disco + OriginalName string `json:"original_name" gorm:"column:original_name"` // nombre original del archivo + TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"` + Tamanio int64 `json:"tamanio" gorm:"column:tamanio"` // bytes + FechaExpedicion *time.Time `json:"fecha_expedicion" gorm:"column:fecha_expedicion"` // opcional +} + +func (ClienteDocumento) TableName() string { return "cliente_documentos" } + +func GetDocumentosByCliente(clienteID uint) ([]ClienteDocumento, error) { + var items []ClienteDocumento + err := app.Http.Database.DB. + Where("cliente_id = ?", clienteID). + Order("created_at DESC"). + Find(&items).Error + return items, err +} + +func CreateClienteDocumento(d *ClienteDocumento) error { + return app.Http.Database.DB.Create(d).Error +} + +func GetClienteDocumentoByID(id uint) (*ClienteDocumento, error) { + var d ClienteDocumento + if err := app.Http.Database.DB.First(&d, id).Error; err != nil { + return nil, err + } + return &d, nil +} + +func DeleteClienteDocumento(id uint) error { + return app.Http.Database.DB.Delete(&ClienteDocumento{}, id).Error +} diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 3216659..0e24d99 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -16,6 +16,7 @@ type Contrato struct { 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"` + Moneda string `json:"moneda" gorm:"column:moneda;default:'COP'"` 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"` @@ -160,6 +161,7 @@ func UpdateContrato(c Contrato, servicioIDs []uint) error { "fecha_inicio": c.FechaInicio, "fecha_vencimiento": c.FechaVencimiento, "precio_acordado": c.PrecioAcordado, + "moneda": c.Moneda, "estado": c.Estado, "auto_renovar": c.AutoRenovar, "notas": c.Notas, diff --git a/pkg/models/saas_producto.go b/pkg/models/saas_producto.go index ee740a3..4d3c798 100644 --- a/pkg/models/saas_producto.go +++ b/pkg/models/saas_producto.go @@ -17,6 +17,7 @@ type SaasProducto struct { Servicio *Servicio `json:"servicio" gorm:"foreignKey:ServicioID"` Activo bool `json:"activo" gorm:"column:activo;default:true"` Orden int `json:"orden" gorm:"column:orden;default:0"` + HealthURL string `json:"health_url" gorm:"column:health_url"` // URL para health check (opcional) } func (SaasProducto) TableName() string { return "saas_productos" } @@ -74,6 +75,7 @@ func UpdateSaasProducto(s *SaasProducto) error { "servicio_id": s.ServicioID, "activo": s.Activo, "orden": s.Orden, + "health_url": s.HealthURL, }).Error } diff --git a/pkg/models/telegram_config.go b/pkg/models/telegram_config.go new file mode 100644 index 0000000..ac362cb --- /dev/null +++ b/pkg/models/telegram_config.go @@ -0,0 +1,80 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// TelegramConfig almacena un bot configurado con su chat_id de destino. +type TelegramConfig struct { + gorm.Model + Nombre string `json:"nombre" gorm:"column:nombre;not null"` + BotToken string `json:"bot_token" gorm:"column:bot_token;type:text;not null"` + ChatID string `json:"chat_id" gorm:"column:chat_id;not null"` // puede ser número o @canal + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Notas string `json:"notas" gorm:"column:notas;type:text"` +} + +func (TelegramConfig) TableName() string { return "telegram_configs" } + +// TelegramLog registra cada mensaje enviado. +type TelegramLog struct { + gorm.Model + TelegramConfigID uint `json:"telegram_config_id" gorm:"column:telegram_config_id;index"` + TelegramConfig TelegramConfig `json:"telegram_config" gorm:"foreignKey:TelegramConfigID"` + Titulo string `json:"titulo" gorm:"column:titulo"` + Mensaje string `json:"mensaje" gorm:"column:mensaje;type:text"` + Estado string `json:"estado" gorm:"column:estado"` // ok | failed + ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"` +} + +func (TelegramLog) TableName() string { return "telegram_logs" } + +// ─── Queries ────────────────────────────────────────────────────────────────── + +func GetAllTelegramConfigs() ([]TelegramConfig, error) { + var items []TelegramConfig + err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error + return items, err +} + +func GetTelegramConfigByID(id uint) (*TelegramConfig, error) { + var item TelegramConfig + err := app.Http.Database.DB.First(&item, id).Error + return &item, err +} + +func CreateTelegramConfig(c *TelegramConfig) error { + return app.Http.Database.DB.Create(c).Error +} + +func UpdateTelegramConfig(c *TelegramConfig) error { + return app.Http.Database.DB.Model(&TelegramConfig{}).Where("id = ?", c.ID).Updates(map[string]interface{}{ + "nombre": c.Nombre, + "bot_token": c.BotToken, + "chat_id": c.ChatID, + "activo": c.Activo, + "notas": c.Notas, + }).Error +} + +func DeleteTelegramConfig(id uint) error { + return app.Http.Database.DB.Delete(&TelegramConfig{}, id).Error +} + +func GetTelegramLogs(limit, offset int) ([]TelegramLog, int64, error) { + var items []TelegramLog + var total int64 + db := app.Http.Database.DB.Model(&TelegramLog{}).Preload("TelegramConfig") + 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 CreateTelegramLog(l *TelegramLog) error { + return app.Http.Database.DB.Create(l).Error +} diff --git a/resources/views/renovaciones/clientes.html b/resources/views/renovaciones/clientes.html index 82f2d24..a4ca402 100644 --- a/resources/views/renovaciones/clientes.html +++ b/resources/views/renovaciones/clientes.html @@ -25,7 +25,7 @@ Email Teléfono Estado - + @@ -40,10 +40,13 @@
- + -
@@ -129,6 +132,120 @@ + +
+
+ +
+
+

Documentos

+

+
+ +
+ + +
+ + +
+

Subir documentos

+ + +
+ + +
+ + +
+ +

Haz clic o arrastra archivos aquí

+

PDF, Word, Excel, imágenes, ZIP — máx. 20 MB c/u

+ +
+ + + +
+ + +
+

Documentos guardados + +

+ +
Cargando…
+ +
+ Sin documentos +
+ +
+ +
+
+
+
+
+ + +
+
+

¿Eliminar documento?

+

+
+ + +
+
+
+
{ form: { nombre:'', empresa:'', email:'', email_cc:'', telefono:'', documento:'', notas:'', activo:true }, toast: { show: false, msg: '', type: 'ok' }, + // ── Documentos ────────────────────────────────────────────────────── + docsModal: false, + docsCliente: null, + documentos: [], + docsLoading: false, + docsUploading: false, + docsUpload: { fechaExpedicion: '', files: [] }, + deleteDocModal: false, + selectedDoc: null, + async init() { await this.loadData(); }, async loadData() { @@ -201,7 +328,96 @@ document.addEventListener('alpine:init', () => { showToast(msg, type='ok') { this.toast = { show: true, msg, type }; setTimeout(() => this.toast.show = false, 3000); - } + }, + + // ── Documentos: métodos ───────────────────────────────────────────── + async openDocs(d) { + this.docsCliente = d; + this.docsModal = true; + this.docsUpload = { fechaExpedicion: '', files: [] }; + await this.loadDocs(); + }, + + async loadDocs() { + this.docsLoading = true; + try { + const { data } = await axios.get(`/app/api/clientes/${this.docsCliente.ID}/documentos`); + this.documentos = data || []; + } catch(e) { + this.showToast('Error cargando documentos', 'error'); + } + this.docsLoading = false; + }, + + addFiles(fileList) { + for (const f of fileList) { + if (!this.docsUpload.files.find(x => x.name === f.name && x.size === f.size)) { + this.docsUpload.files.push(f); + } + } + }, + + handleDrop(event) { + this.addFiles(event.dataTransfer.files); + }, + + removeFile(i) { + this.docsUpload.files.splice(i, 1); + }, + + async uploadDocs() { + if (this.docsUpload.files.length === 0) return; + this.docsUploading = true; + try { + const fd = new FormData(); + for (const f of this.docsUpload.files) { + fd.append('archivos', f); + } + if (this.docsUpload.fechaExpedicion) { + fd.append('fecha_expedicion', this.docsUpload.fechaExpedicion); + } + await axios.post(`/app/api/clientes/${this.docsCliente.ID}/documentos`, fd, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + this.showToast('Archivos subidos correctamente'); + this.docsUpload.files = []; + this.docsUpload.fechaExpedicion = ''; + this.$refs.fileInput && (this.$refs.fileInput.value = ''); + await this.loadDocs(); + } catch(e) { + this.showToast(e.response?.data?.error || 'Error al subir', 'error'); + } + this.docsUploading = false; + }, + + confirmDeleteDoc(doc) { + this.selectedDoc = doc; + this.deleteDocModal = true; + }, + + async deleteDoc() { + try { + await axios.delete(`/app/api/clientes/${this.docsCliente.ID}/documentos/${this.selectedDoc.ID}`); + this.showToast('Documento eliminado'); + this.deleteDocModal = false; + this.selectedDoc = null; + await this.loadDocs(); + } catch(e) { + this.showToast(e.response?.data?.error || 'Error al eliminar', 'error'); + } + }, + + formatSize(bytes) { + if (!bytes) return '0 B'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB'; + return (bytes/(1024*1024)).toFixed(1) + ' MB'; + }, + + fmtDate(iso) { + if (!iso) return ''; + return iso.substring(0, 10); + }, })); }); diff --git a/resources/views/renovaciones/contratos.html b/resources/views/renovaciones/contratos.html index 8857acc..56da402 100644 --- a/resources/views/renovaciones/contratos.html +++ b/resources/views/renovaciones/contratos.html @@ -55,7 +55,7 @@ :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'"> - + Precio acordado

- Sugerido: + Sugerido:

+
+ + +
+
+ + +

Se usará para verificar que el servicio está activo. Debe devolver HTTP 2xx.

+
+

Se enviará tal cual al endpoint. Si está vacío se envía sin body.

+
+ + +
+
+ HTTP + + + +
+

+                
+ +
+ +
+ + +