fix: compilar agent/dist en builder stage y agregar módulos contabilidad/websms
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ca6a44f64e
commit
e28e2823dc
@@ -16,6 +16,13 @@ COPY . .
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o apiv2 .
|
||||
|
||||
# Compilar agente para múltiples plataformas
|
||||
RUN cd agent && go mod download && \
|
||||
mkdir -p /app/agent/dist && \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/agent/dist/usite-agent-linux-amd64 . && \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o /app/agent/dist/usite-agent-linux-arm64 . && \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags="-s -w" -o /app/agent/dist/usite-agent-linux-arm .
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Stage 3: Imagen final mínima
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
@@ -91,6 +91,17 @@ func main() {
|
||||
&models.Servidor{},
|
||||
&models.ConxDb{},
|
||||
&models.ProvServidor{},
|
||||
// WebSMS (LabsMobile)
|
||||
&models.WebSmsConfig{},
|
||||
&models.WebSmsLog{},
|
||||
&models.WebSmsWebhookLog{},
|
||||
// Contabilidad
|
||||
&models.Cuenta{},
|
||||
&models.Entidad{},
|
||||
&models.Transaccion{},
|
||||
&models.CuentaCobro{},
|
||||
&models.CuentaPagar{},
|
||||
&models.ConsolidadoMensual{},
|
||||
)
|
||||
// Seed automático (idempotente) de módulos del sistema
|
||||
migrations.SeedRenovaciones()
|
||||
@@ -105,6 +116,9 @@ func main() {
|
||||
migrations.SeedNotifDefaults()
|
||||
migrations.SeedShield()
|
||||
migrations.SeedPartnerRecursos()
|
||||
models.SeedContabilidad()
|
||||
migrations.SeedContabilidadMenu()
|
||||
migrations.SeedWebSms()
|
||||
// Iniciar cron de vencimientos
|
||||
services.IniciarCron()
|
||||
defer services.DetenerCron()
|
||||
|
||||
@@ -912,3 +912,90 @@ func SeedPartnerRecursos() {
|
||||
}
|
||||
log.Println("[SEED] Seed de Partner Recursos completado.")
|
||||
}
|
||||
|
||||
// SeedContabilidadMenu crea el módulo "Contabilidad" y sus submódulos en el menú lateral.
|
||||
func SeedContabilidadMenu() {
|
||||
db := app.Http.Database.DB
|
||||
var modulo models.Modules
|
||||
result := db.Where("title = ?", "Contabilidad").First(&modulo)
|
||||
if result.Error != nil {
|
||||
modulo = models.Modules{
|
||||
Title: "Contabilidad",
|
||||
Description: "Gestión contable: ingresos, egresos, cuentas por cobrar/pagar",
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&modulo).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando módulo Contabilidad: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Módulo 'Contabilidad' creado con ID %d", modulo.ID)
|
||||
} else {
|
||||
log.Printf("[SEED] Módulo 'Contabilidad' ya existe (ID %d)", modulo.ID)
|
||||
}
|
||||
entries := []struct{ title, desc, url string }{
|
||||
{"Dashboard", "Resumen mensual", "/app/contabilidad"},
|
||||
{"Transacciones", "Ingresos y egresos", "/app/contabilidad/transacciones"},
|
||||
{"Categorías", "Plan de cuentas", "/app/contabilidad/cuentas"},
|
||||
{"Entidades", "Clientes y proveedores", "/app/contabilidad/entidades"},
|
||||
{"Cuentas por cobrar", "Pendientes de cobro", "/app/contabilidad/cuentas-cobro"},
|
||||
{"Cuentas por pagar", "Pendientes de pago", "/app/contabilidad/cuentas-pagar"},
|
||||
}
|
||||
var insertados []models.Submodules
|
||||
for _, e := range entries {
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: e.title, Description: e.desc, Url: e.url,
|
||||
ModuleId: modulo.ID, ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
|
||||
continue
|
||||
}
|
||||
} else if sub.ModuleId != modulo.ID {
|
||||
db.Model(&sub).Update("module_id", modulo.ID)
|
||||
}
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
var roles []models.Roles
|
||||
db.Find(&roles)
|
||||
for _, rol := range roles {
|
||||
db.Model(&rol).Association("Submodules").Append(&insertados)
|
||||
}
|
||||
log.Println("[SEED] Seed de Contabilidad completado.")
|
||||
}
|
||||
|
||||
// SeedWebSms crea el submódulo de WebSMS bajo "Integraciones".
|
||||
func SeedWebSms() {
|
||||
db := app.Http.Database.DB
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
|
||||
log.Println("[SEED] Módulo 'Integraciones' no encontrado, se creará")
|
||||
modulo = models.Modules{
|
||||
Title: "Integraciones", Description: "Conexión con servicios externos",
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
db.Create(&modulo)
|
||||
}
|
||||
url := "/app/websms"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "WebSMS", Description: "Notificaciones SMS vía LabsMobile",
|
||||
Url: url, ModuleId: modulo.ID, ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo WebSMS: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Submódulo 'WebSMS' creado")
|
||||
} else if sub.ModuleId != modulo.ID {
|
||||
db.Model(&sub).Update("module_id", modulo.ID)
|
||||
}
|
||||
var roles []models.Roles
|
||||
db.Find(&roles)
|
||||
for _, rol := range roles {
|
||||
db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub})
|
||||
}
|
||||
log.Println("[SEED] Seed de WebSMS completado.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── Cuenta (plan de cuentas / categoría contable) ───────────────────────────
|
||||
|
||||
type Cuenta struct {
|
||||
gorm.Model
|
||||
Codigo string `json:"codigo" gorm:"column:codigo;size:20"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:255;not null"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20;not null"` // ingreso | egreso
|
||||
Color string `json:"color" gorm:"column:color;size:7"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (Cuenta) TableName() string { return "contab_cuentas" }
|
||||
|
||||
// ─── Entidad (cliente, proveedor, empresa, persona) ──────────────────────────
|
||||
|
||||
type Entidad struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:255;not null"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // cliente | proveedor | ambos
|
||||
Documento string `json:"documento" gorm:"column:documento;size:50"`
|
||||
Email string `json:"email" gorm:"column:email;size:255"`
|
||||
Telefono string `json:"telefono" gorm:"column:telefono;size:50"`
|
||||
Contacto string `json:"contacto" gorm:"column:contacto;size:255"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (Entidad) TableName() string { return "contab_entidades" }
|
||||
|
||||
// ─── Transaccion (ingreso / egreso) ──────────────────────────────────────────
|
||||
|
||||
type Transaccion struct {
|
||||
gorm.Model
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:10;not null"` // ingreso | egreso
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
CuentaID *uint `json:"cuenta_id" gorm:"column:cuenta_id;index"`
|
||||
Cuenta *Cuenta `json:"cuenta" gorm:"foreignKey:CuentaID"`
|
||||
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
|
||||
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
FormaPago string `json:"forma_pago" gorm:"column:forma_pago;size:30"` // transferencia | efectivo | tarjeta | cheque | otro
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'registrada'"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
}
|
||||
|
||||
func (Transaccion) TableName() string { return "contab_transacciones" }
|
||||
|
||||
// ─── CuentaCobro (cuentas por cobrar) ────────────────────────────────────────
|
||||
|
||||
type CuentaCobro struct {
|
||||
gorm.Model
|
||||
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
|
||||
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
|
||||
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
}
|
||||
|
||||
func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
|
||||
|
||||
// ─── CuentaPagar (cuentas por pagar) ─────────────────────────────────────────
|
||||
|
||||
type CuentaPagar struct {
|
||||
gorm.Model
|
||||
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
|
||||
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
}
|
||||
|
||||
func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
|
||||
|
||||
// ─── ConsolidadoMensual ──────────────────────────────────────────────────────
|
||||
|
||||
type ConsolidadoMensual struct {
|
||||
gorm.Model
|
||||
Anio int `json:"anio" gorm:"column:anio;not null"`
|
||||
Mes int `json:"mes" gorm:"column:mes;not null"`
|
||||
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
|
||||
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
|
||||
TotalRetenciones float64 `json:"total_retenciones" gorm:"column:total_retenciones;default:0"`
|
||||
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
|
||||
}
|
||||
|
||||
func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Cuenta ───────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
|
||||
var items []Cuenta
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Cuenta{})
|
||||
if search != "" {
|
||||
db = db.Where("nombre ILIKE ? OR codigo ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("tipo ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetAllCuentasSelect() ([]Cuenta, error) {
|
||||
var items []Cuenta
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func CreateCuenta(c *Cuenta) error {
|
||||
return app.Http.Database.DB.Create(c).Error
|
||||
}
|
||||
|
||||
func UpdateCuenta(c *Cuenta) error {
|
||||
return app.Http.Database.DB.Model(&Cuenta{}).Where("id = ?", c.ID).Updates(map[string]interface{}{
|
||||
"codigo": c.Codigo,
|
||||
"nombre": c.Nombre,
|
||||
"tipo": c.Tipo,
|
||||
"color": c.Color,
|
||||
"activo": c.Activo,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteCuenta(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Cuenta{}, id).Error
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Entidad ──────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error) {
|
||||
var items []Entidad
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Entidad{})
|
||||
if search != "" {
|
||||
db = db.Where("nombre ILIKE ? OR documento ILIKE ? OR email ILIKE ?",
|
||||
"%"+search+"%", "%"+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 GetAllEntidadesSelect() ([]Entidad, error) {
|
||||
var items []Entidad
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func CreateEntidad(e *Entidad) error {
|
||||
return app.Http.Database.DB.Create(e).Error
|
||||
}
|
||||
|
||||
func UpdateEntidad(e *Entidad) error {
|
||||
return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{
|
||||
"nombre": e.Nombre,
|
||||
"tipo": e.Tipo,
|
||||
"documento": e.Documento,
|
||||
"email": e.Email,
|
||||
"telefono": e.Telefono,
|
||||
"contacto": e.Contacto,
|
||||
"notas": e.Notas,
|
||||
"activo": e.Activo,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteEntidad(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Entidad{}, id).Error
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Transaccion ──────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetAllTransacciones(limit, offset int, search string, filtroTipo string, mes int, anio int) ([]Transaccion, int64, error) {
|
||||
var items []Transaccion
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Transaccion{}).Preload("Cuenta").Preload("Entidad")
|
||||
if search != "" {
|
||||
db = db.Where("descripcion ILIKE ?", "%"+search+"%")
|
||||
}
|
||||
if filtroTipo != "" {
|
||||
db = db.Where("tipo = ?", filtroTipo)
|
||||
}
|
||||
if mes > 0 && anio > 0 {
|
||||
db = db.Where("EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", mes, anio)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("fecha DESC, created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func CreateTransaccion(t *Transaccion) error {
|
||||
return app.Http.Database.DB.Create(t).Error
|
||||
}
|
||||
|
||||
func UpdateTransaccion(t *Transaccion) error {
|
||||
return app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
|
||||
"fecha": t.Fecha,
|
||||
"tipo": t.Tipo,
|
||||
"descripcion": t.Descripcion,
|
||||
"valor": t.Valor,
|
||||
"cuenta_id": t.CuentaID,
|
||||
"entidad_id": t.EntidadID,
|
||||
"forma_pago": t.FormaPago,
|
||||
"estado": t.Estado,
|
||||
"notas": t.Notas,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteTransaccion(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Transaccion{}, id).Error
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: CuentaCobro ──────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]CuentaCobro, int64, error) {
|
||||
var items []CuentaCobro
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Entidad").Preload("Transaccion")
|
||||
if search != "" {
|
||||
db = db.Joins("JOIN contab_entidades ON contab_entidades.id = contab_cuentas_cobro.entidad_id").
|
||||
Where("contab_entidades.nombre ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?",
|
||||
"%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if estado != "" {
|
||||
db = db.Where("contab_cuentas_cobro.estado = ?", estado)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("contab_cuentas_cobro.fecha DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func CreateCuentaCobro(cc *CuentaCobro) error {
|
||||
return app.Http.Database.DB.Create(cc).Error
|
||||
}
|
||||
|
||||
func UpdateCuentaCobro(cc *CuentaCobro) error {
|
||||
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{
|
||||
"entidad_id": cc.EntidadID,
|
||||
"fecha": cc.Fecha,
|
||||
"descripcion": cc.Descripcion,
|
||||
"valor": cc.Valor,
|
||||
"estado": cc.Estado,
|
||||
"fecha_vencimiento": cc.FechaVencimiento,
|
||||
"fecha_pago": cc.FechaPago,
|
||||
"transaccion_id": cc.TransaccionID,
|
||||
"notas": cc.Notas,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteCuentaCobro(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: CuentaPagar ──────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetAllCuentasPagar(limit, offset int, search string, estado string) ([]CuentaPagar, int64, error) {
|
||||
var items []CuentaPagar
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&CuentaPagar{}).Preload("Entidad").Preload("Transaccion")
|
||||
if search != "" {
|
||||
db = db.Joins("JOIN contab_entidades ON contab_entidades.id = contab_cuentas_pagar.entidad_id").
|
||||
Where("contab_entidades.nombre ILIKE ? OR contab_cuentas_pagar.descripcion ILIKE ?",
|
||||
"%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if estado != "" {
|
||||
db = db.Where("contab_cuentas_pagar.estado = ?", estado)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("contab_cuentas_pagar.fecha DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func CreateCuentaPagar(cp *CuentaPagar) error {
|
||||
return app.Http.Database.DB.Create(cp).Error
|
||||
}
|
||||
|
||||
func UpdateCuentaPagar(cp *CuentaPagar) error {
|
||||
return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", cp.ID).Updates(map[string]interface{}{
|
||||
"entidad_id": cp.EntidadID,
|
||||
"fecha": cp.Fecha,
|
||||
"descripcion": cp.Descripcion,
|
||||
"valor": cp.Valor,
|
||||
"vencimiento": cp.Vencimiento,
|
||||
"estado": cp.Estado,
|
||||
"fecha_pago": cp.FechaPago,
|
||||
"transaccion_id": cp.TransaccionID,
|
||||
"notas": cp.Notas,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteCuentaPagar(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&CuentaPagar{}, id).Error
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── Consolidado ────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetConsolidadoMensual(mes, anio int) (*ConsolidadoMensual, error) {
|
||||
var c ConsolidadoMensual
|
||||
err := app.Http.Database.DB.Where("mes = ? AND anio = ?", mes, anio).First(&c).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
|
||||
type SumRow struct {
|
||||
Total float64
|
||||
}
|
||||
var ingresos SumRow
|
||||
var egresos SumRow
|
||||
|
||||
app.Http.Database.DB.Model(&Transaccion{}).
|
||||
Select("COALESCE(SUM(valor),0) as total").
|
||||
Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "ingreso", mes, anio).
|
||||
Scan(&ingresos)
|
||||
|
||||
app.Http.Database.DB.Model(&Transaccion{}).
|
||||
Select("COALESCE(SUM(valor),0) as total").
|
||||
Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "egreso", mes, anio).
|
||||
Scan(&egresos)
|
||||
|
||||
resultado := ingresos.Total - egresos.Total
|
||||
|
||||
c := &ConsolidadoMensual{
|
||||
Anio: anio,
|
||||
Mes: mes,
|
||||
TotalIngresos: ingresos.Total,
|
||||
TotalEgresos: egresos.Total,
|
||||
TotalRetenciones: 0,
|
||||
Resultado: resultado,
|
||||
}
|
||||
|
||||
var existing ConsolidadoMensual
|
||||
err := app.Http.Database.DB.Where("mes = ? AND anio = ?", mes, anio).First(&existing).Error
|
||||
if err == nil {
|
||||
c.ID = existing.ID
|
||||
app.Http.Database.DB.Model(&ConsolidadoMensual{}).Where("id = ?", c.ID).Updates(map[string]interface{}{
|
||||
"total_ingresos": c.TotalIngresos,
|
||||
"total_egresos": c.TotalEgresos,
|
||||
"total_retenciones": c.TotalRetenciones,
|
||||
"resultado": c.Resultado,
|
||||
})
|
||||
} else {
|
||||
app.Http.Database.DB.Create(c)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
|
||||
var items []ConsolidadoMensual
|
||||
db := app.Http.Database.DB.Model(&ConsolidadoMensual{}).Order("anio DESC, mes DESC")
|
||||
if anio > 0 {
|
||||
db = db.Where("anio = ?", anio)
|
||||
}
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ─── Datos para dashboard ────────────────────────────────────────────────────
|
||||
|
||||
type DashboardData struct {
|
||||
Mes int `json:"mes"`
|
||||
Anio int `json:"anio"`
|
||||
TotalIngresos float64 `json:"total_ingresos"`
|
||||
TotalEgresos float64 `json:"total_egresos"`
|
||||
Resultado float64 `json:"resultado"`
|
||||
CantTransacciones int64 `json:"cant_transacciones"`
|
||||
PendientesCobro float64 `json:"pendientes_cobro"`
|
||||
PendientesPago float64 `json:"pendientes_pago"`
|
||||
Transacciones []Transaccion `json:"transacciones"`
|
||||
}
|
||||
|
||||
func GetDashboardData(mes, anio int) (*DashboardData, error) {
|
||||
d := &DashboardData{Mes: mes, Anio: anio}
|
||||
|
||||
app.Http.Database.DB.Model(&Transaccion{}).
|
||||
Select("COALESCE(SUM(valor),0) as total").
|
||||
Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "ingreso", mes, anio).
|
||||
Scan(&d.TotalIngresos)
|
||||
|
||||
app.Http.Database.DB.Model(&Transaccion{}).
|
||||
Select("COALESCE(SUM(valor),0) as total").
|
||||
Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "egreso", mes, anio).
|
||||
Scan(&d.TotalEgresos)
|
||||
|
||||
d.Resultado = d.TotalIngresos - d.TotalEgresos
|
||||
|
||||
app.Http.Database.DB.Model(&Transaccion{}).
|
||||
Where("EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", mes, anio).
|
||||
Count(&d.CantTransacciones)
|
||||
|
||||
app.Http.Database.DB.Model(&CuentaCobro{}).
|
||||
Select("COALESCE(SUM(valor),0) as total").
|
||||
Where("estado = ?", "pendiente").
|
||||
Scan(&d.PendientesCobro)
|
||||
|
||||
app.Http.Database.DB.Model(&CuentaPagar{}).
|
||||
Select("COALESCE(SUM(valor),0) as total").
|
||||
Where("estado = ?", "pendiente").
|
||||
Scan(&d.PendientesPago)
|
||||
|
||||
app.Http.Database.DB.Model(&Transaccion{}).
|
||||
Preload("Cuenta").Preload("Entidad").
|
||||
Where("EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", mes, anio).
|
||||
Order("fecha DESC").
|
||||
Limit(10).
|
||||
Find(&d.Transacciones)
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func SeedContabilidad() {
|
||||
cuentas := []Cuenta{
|
||||
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
|
||||
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
|
||||
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
|
||||
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
|
||||
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
|
||||
{Codigo: "EGR-GRAL", Nombre: "Gastos generales", Tipo: "egreso", Color: "#eab308"},
|
||||
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
|
||||
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
|
||||
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
|
||||
}
|
||||
for _, c := range cuentas {
|
||||
var existing Cuenta
|
||||
if app.Http.Database.DB.Where("codigo = ?", c.Codigo).First(&existing).Error != nil {
|
||||
app.Http.Database.DB.Create(&c)
|
||||
}
|
||||
}
|
||||
|
||||
entidades := []Entidad{
|
||||
{Nombre: "ANDREMER", Tipo: "cliente"},
|
||||
{Nombre: "GIAF SAS", Tipo: "cliente"},
|
||||
{Nombre: "TECZONE", Tipo: "cliente"},
|
||||
{Nombre: "DOCUXER", Tipo: "cliente"},
|
||||
{Nombre: "FELIPE", Tipo: "cliente"},
|
||||
{Nombre: "NATALIA", Tipo: "cliente"},
|
||||
}
|
||||
for _, e := range entidades {
|
||||
var existing Entidad
|
||||
if app.Http.Database.DB.Where("nombre = ?", e.Nombre).First(&existing).Error != nil {
|
||||
app.Http.Database.DB.Create(&e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebSmsConfig struct {
|
||||
gorm.Model
|
||||
Username string `json:"username" gorm:"column:username;size:255;not null"`
|
||||
ApiToken string `json:"api_token" gorm:"column:api_token;type:text;not null"`
|
||||
Sender string `json:"sender" gorm:"column:sender;size:30"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (WebSmsConfig) TableName() string { return "websms_config" }
|
||||
|
||||
func GetWebSmsConfig() (*WebSmsConfig, error) {
|
||||
var item WebSmsConfig
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func SaveWebSmsConfig(s WebSmsConfig) error {
|
||||
app.Http.Database.DB.Model(&WebSmsConfig{}).
|
||||
Where("activo = ?", true).
|
||||
Update("activo", false)
|
||||
s.Activo = true
|
||||
if s.ID > 0 {
|
||||
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"username": s.Username,
|
||||
"api_token": s.ApiToken,
|
||||
"sender": s.Sender,
|
||||
"notas": s.Notas,
|
||||
"activo": true,
|
||||
}).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(&s).Error
|
||||
}
|
||||
|
||||
// ─── Log de SMS enviados ────────────────────────────────────────────────────
|
||||
|
||||
type WebSmsLog struct {
|
||||
gorm.Model
|
||||
Para string `json:"para" gorm:"column:para;size:30;index"`
|
||||
Mensaje string `json:"mensaje" gorm:"column:mensaje;type:text"`
|
||||
Status string `json:"status" gorm:"column:status;size:20"`
|
||||
MsgID string `json:"msg_id" gorm:"column:msg_id;size:100"`
|
||||
Error string `json:"error" gorm:"column:error;type:text"`
|
||||
}
|
||||
|
||||
func (WebSmsLog) TableName() string { return "websms_log" }
|
||||
|
||||
func CreateWebSmsLog(entry *WebSmsLog) error {
|
||||
return app.Http.Database.DB.Create(entry).Error
|
||||
}
|
||||
|
||||
func GetWebSmsLogs(limit int) ([]WebSmsLog, error) {
|
||||
var logs []WebSmsLog
|
||||
if err := app.Http.Database.DB.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// ─── Webhook ACK log ────────────────────────────────────────────────────────
|
||||
|
||||
type WebSmsWebhookLog struct {
|
||||
gorm.Model
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20;index"` // delivery | click | incoming
|
||||
MsgID string `json:"msg_id" gorm:"column:msg_id;size:100;index"`
|
||||
Para string `json:"para" gorm:"column:para;size:30"`
|
||||
Status string `json:"status" gorm:"column:status;size:30"`
|
||||
Raw string `json:"raw" gorm:"column:raw;type:text"`
|
||||
}
|
||||
|
||||
func (WebSmsWebhookLog) TableName() string { return "websms_webhook_log" }
|
||||
|
||||
func CreateWebSmsWebhookLog(entry *WebSmsWebhookLog) error {
|
||||
return app.Http.Database.DB.Create(entry).Error
|
||||
}
|
||||
|
||||
func GetWebSmsWebhookLogs(limit int) ([]WebSmsWebhookLog, error) {
|
||||
var logs []WebSmsWebhookLog
|
||||
if err := app.Http.Database.DB.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
const websmsAPIBase = "https://websms.labsmobile.com/SY0204/api"
|
||||
|
||||
type WebSmsRequest struct {
|
||||
Message string `json:"message"`
|
||||
TPOA string `json:"tpoa,omitempty"`
|
||||
Recipient []WebSmsRecipient `json:"recipient"`
|
||||
}
|
||||
|
||||
type WebSmsRecipient struct {
|
||||
MSISDN string `json:"msisdn"`
|
||||
}
|
||||
|
||||
type WebSmsResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type WebSmsAckPayload struct {
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"`
|
||||
Status string `json:"status"`
|
||||
Msisdn string `json:"msisdn"`
|
||||
Substatus string `json:"substatus"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
type WebSmsClickPayload struct {
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"`
|
||||
Msisdn string `json:"msisdn"`
|
||||
URL string `json:"url"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
type WebSmsIncomingPayload struct {
|
||||
ID string `json:"id"`
|
||||
Msisdn string `json:"msisdn"`
|
||||
Message string `json:"message"`
|
||||
Shortcode string `json:"shortcode"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func SendWebSms(cfg *models.WebSmsConfig, para, mensaje string) (*WebSmsResponse, error) {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.ApiToken))
|
||||
|
||||
req := WebSmsRequest{
|
||||
Message: mensaje,
|
||||
Recipient: []WebSmsRecipient{{MSISDN: para}},
|
||||
}
|
||||
if cfg.Sender != "" {
|
||||
req.TPOA = cfg.Sender
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websms: marshal: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", websmsAPIBase, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Basic "+auth)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websms: http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||
return nil, fmt.Errorf("websms API status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result WebSmsResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("websms: unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<div x-data="contabilidadApp()" x-init="init()" class="p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Contabilidad</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Consolidado mensual de ingresos y egresos</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select x-model="mes" @change="loadDashboard()" class="input-field">
|
||||
<template x-for="m in 12" :key="m">
|
||||
<option :value="m" x-text="meses[m-1]"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select x-model="anio" @change="loadDashboard()" class="input-field">
|
||||
<option x-text="anio-2" :value="anio-2"></option>
|
||||
<option x-text="anio-1" :value="anio-1"></option>
|
||||
<option x-text="anio" :value="anio" selected></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tarjetas de resumen -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Ingresos</p>
|
||||
<p class="text-2xl font-bold text-green-600" x-text="formatoCOP(data.total_ingresos)"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Egresos</p>
|
||||
<p class="text-2xl font-bold text-red-600" x-text="formatoCOP(data.total_egresos)"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Resultado</p>
|
||||
<p class="text-2xl font-bold" :class="data.resultado >= 0 ? 'text-green-600' : 'text-red-600'" x-text="formatoCOP(data.resultado)"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Transacciones</p>
|
||||
<p class="text-2xl font-bold text-blue-600" x-text="data.cant_transacciones"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pendientes -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-semibold text-slate-700">Por cobrar</p>
|
||||
<a href="/app/contabilidad/cuentas-cobro" class="text-xs text-blue-500 hover:underline">Ver todas</a>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-amber-600" x-text="formatoCOP(data.pendientes_cobro)"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-semibold text-slate-700">Por pagar</p>
|
||||
<a href="/app/contabilidad/cuentas-pagar" class="text-xs text-blue-500 hover:underline">Ver todas</a>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-red-600" x-text="formatoCOP(data.pendientes_pago)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Últimas transacciones -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold text-slate-800">Últimas transacciones</h2>
|
||||
<a href="/app/contabilidad/transacciones" class="text-sm text-blue-500 hover:underline">Ver todas</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Fecha</th>
|
||||
<th class="px-3 py-2 text-left">Tipo</th>
|
||||
<th class="px-3 py-2 text-left">Descripción</th>
|
||||
<th class="px-3 py-2 text-left">Categoría</th>
|
||||
<th class="px-3 py-2 text-left">Entidad</th>
|
||||
<th class="px-3 py-2 text-right">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-for="t in data.transacciones" :key="t.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-3 py-2 text-xs text-slate-500" x-text="formatoFecha(t.fecha)"></td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge" :class="t.tipo==='ingreso'?'badge-green':'badge-red'" x-text="t.tipo"></span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-slate-700" x-text="t.descripcion"></td>
|
||||
<td class="px-3 py-2 text-slate-600" x-text="t.cuenta?.nombre||'-'"></td>
|
||||
<td class="px-3 py-2 text-slate-600" x-text="t.entidad?.nombre||'-'"></td>
|
||||
<td class="px-3 py-2 text-right font-semibold" :class="t.tipo==='ingreso'?'text-green-600':'text-red-600'" x-text="formatoCOP(t.valor)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!data.transacciones?.length">
|
||||
<tr><td colspan="6" class="text-center py-6 text-slate-400">Sin transacciones este mes</td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function contabilidadApp() {
|
||||
return {
|
||||
data:{transacciones:[]},
|
||||
mes:new Date().getMonth()+1,
|
||||
anio:new Date().getFullYear(),
|
||||
meses:['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'],
|
||||
|
||||
async init(){ await this.loadDashboard(); },
|
||||
|
||||
async loadDashboard(){
|
||||
try {
|
||||
const r=await axios.get(`/app/contabilidad/dashboard?mes=${this.mes}&anio=${this.anio}`);
|
||||
this.data=r.data;
|
||||
} catch(e){ console.error(e); }
|
||||
},
|
||||
|
||||
formatoCOP(n){ if(n==null) return '$0'; return '$ '+Number(n).toLocaleString('es-CO',{minimumFractionDigits:0}); },
|
||||
formatoFecha(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<div x-data="cuentasApp()" x-init="init()" class="p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Categorías contables</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Plan de cuentas para clasificar transacciones</p>
|
||||
</div>
|
||||
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nueva categoría</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Código</th>
|
||||
<th class="px-4 py-3 text-left">Nombre</th>
|
||||
<th class="px-4 py-3 text-left">Tipo</th>
|
||||
<th class="px-4 py-3 text-left">Color</th>
|
||||
<th class="px-4 py-3 text-left">Activo</th>
|
||||
<th class="px-4 py-3 text-left">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-if="loading">
|
||||
<tr><td colspan="6" class="text-center py-10 text-slate-400">Cargando...</td></tr>
|
||||
</template>
|
||||
<template x-for="c in items" :key="c.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500" x-text="c.codigo||'-'"></td>
|
||||
<td class="px-4 py-3 text-slate-700">
|
||||
<span class="inline-block w-3 h-3 rounded-full mr-2" :style="'background:'+(c.color||'#ccc')"></span>
|
||||
<span x-text="c.nombre"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="badge" :class="c.tipo==='ingreso'?'badge-green':'badge-red'" x-text="c.tipo"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500" x-text="c.color||'-'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span x-show="c.activo" class="text-green-500">Activo</span>
|
||||
<span x-show="!c.activo" class="text-red-400">Inactivo</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEdit(c)" class="btn-icon text-yellow-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(c)" class="btn-icon text-red-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4" x-text="editId ? 'Editar categoría' : 'Nueva categoría'"></h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Código</label>
|
||||
<input x-model="form.codigo" class="input-field w-full" placeholder="ING-FAC">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Tipo</label>
|
||||
<select x-model="form.tipo" class="input-field w-full" required>
|
||||
<option value="ingreso">Ingreso</option>
|
||||
<option value="egreso">Egreso</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Nombre</label>
|
||||
<input x-model="form.nombre" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Color</label>
|
||||
<input x-model="form.color" type="color" class="input-field w-full h-10">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-5">
|
||||
<input type="checkbox" x-model="form.activo" id="ca" class="w-4 h-4">
|
||||
<label for="ca" class="text-sm text-slate-600">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-2">¿Eliminar categoría?</h2>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function cuentasApp() {
|
||||
return {
|
||||
items:[], total:0, totalPages:1, page:1,
|
||||
loading:false, saving:false, showModal:false, showDelete:false,
|
||||
editId:null, deleteId:null, error:'',
|
||||
form:{ codigo:'', nombre:'', tipo:'egreso', color:'#3b82f6', activo:true },
|
||||
|
||||
async init(){ await this.load(); },
|
||||
|
||||
async load(){
|
||||
this.loading=true;
|
||||
try {
|
||||
const r=await axios.get(`/app/contabilidad/cuentas/list?page=${this.page}`);
|
||||
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
|
||||
} finally{ this.loading=false; }
|
||||
},
|
||||
|
||||
openCreate(){ this.editId=null; this.error=''; this.form={codigo:'',nombre:'',tipo:'egreso',color:'#3b82f6',activo:true}; this.showModal=true; },
|
||||
openEdit(c){
|
||||
this.editId=c.ID; this.error='';
|
||||
this.form={codigo:c.codigo||'',nombre:c.nombre,tipo:c.tipo,color:c.color||'#3b82f6',activo:c.activo};
|
||||
this.showModal=true;
|
||||
},
|
||||
async save(){
|
||||
this.saving=true; this.error='';
|
||||
try {
|
||||
if(this.editId) await axios.put(`/app/contabilidad/cuentas/${this.editId}`, this.form);
|
||||
else await axios.post('/app/contabilidad/cuentas', this.form);
|
||||
this.showModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
confirmDelete(c){ this.deleteId=c.ID; this.showDelete=true; },
|
||||
async doDelete(){
|
||||
this.saving=true;
|
||||
try{ await axios.delete(`/app/contabilidad/cuentas/${this.deleteId}`); this.showDelete=false; await this.load(); }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
<div x-data="cobroApp()" x-init="init()" class="p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Cuentas por cobrar</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Facturas y montos pendientes de cobro</p>
|
||||
</div>
|
||||
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nuevo cobro</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar..." class="input-field w-full max-w-xs">
|
||||
<select x-model="filtroEstado" @change="page=1;load()" class="input-field">
|
||||
<option value="">Todos</option>
|
||||
<option value="pendiente">Pendiente</option>
|
||||
<option value="pagado">Pagado</option>
|
||||
<option value="parcial">Parcial</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Fecha</th>
|
||||
<th class="px-4 py-3 text-left">Entidad</th>
|
||||
<th class="px-4 py-3 text-left">Descripción</th>
|
||||
<th class="px-4 py-3 text-right">Valor</th>
|
||||
<th class="px-4 py-3 text-left">Vence</th>
|
||||
<th class="px-4 py-3 text-left">Estado</th>
|
||||
<th class="px-4 py-3 text-left">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-if="loading">
|
||||
<tr><td colspan="7" class="text-center py-10 text-slate-400">Cargando...</td></tr>
|
||||
</template>
|
||||
<template x-for="c in items" :key="c.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.fecha)"></td>
|
||||
<td class="px-4 py-3 text-slate-700 font-medium" x-text="c.entidad?.nombre||'-'"></td>
|
||||
<td class="px-4 py-3 text-slate-600" x-text="c.descripcion"></td>
|
||||
<td class="px-4 py-3 text-right font-semibold text-amber-600" x-text="formatoCOP(c.valor)"></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.fecha_vencimiento)"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="badge" :class="{'badge-yellow':c.estado==='pendiente','badge-green':c.estado==='pagado','badge-blue':c.estado==='parcial'}" x-text="c.estado"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="marcarPagado(c)" class="btn-icon text-green-500" title="Marcar pagado" x-show="c.estado!=='pagado'">✓</button>
|
||||
<button @click="confirmDelete(c)" class="btn-icon text-red-500">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
|
||||
<span>Total: <strong x-text="total"></strong></span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="page--;load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
|
||||
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
|
||||
<button @click="page++;load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Nuevo cobro pendiente</h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="label">Entidad (quien debe)</label>
|
||||
<select x-model.number="form.entidad_id" class="input-field w-full" required>
|
||||
<option value="">Seleccionar...</option>
|
||||
<template x-for="e in entidades" :key="e.ID">
|
||||
<option :value="e.ID" x-text="e.nombre"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Descripción</label>
|
||||
<input x-model="form.descripcion" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Valor ($)</label>
|
||||
<input x-model.number="form.valor" type="number" step="0.01" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Fecha</label>
|
||||
<input x-model="form.fecha" type="date" class="input-field w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Fecha vencimiento</label>
|
||||
<input x-model="form.fecha_vencimiento" type="date" class="input-field w-full">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Notas</label>
|
||||
<textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal pagado -->
|
||||
<div x-show="showPagarModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showPagarModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Marcar como pagado</h2>
|
||||
<form @submit.prevent="doPagar()">
|
||||
<label class="label">Fecha de pago</label>
|
||||
<input x-model="pagoFecha" type="date" class="input-field w-full mb-4" required>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="showPagarModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Confirmar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-2">¿Eliminar?</h2>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function cobroApp() {
|
||||
return {
|
||||
items:[], entidades:[], total:0, totalPages:1, page:1, search:'', filtroEstado:'',
|
||||
loading:false, saving:false, showModal:false, showDelete:false, showPagarModal:false,
|
||||
deleteId:null, pagarId:null, pagoFecha:'', error:'',
|
||||
form:{ entidad_id:'', descripcion:'', valor:0, fecha:'', fecha_vencimiento:'', notas:'' },
|
||||
|
||||
async init(){ await this.load(); },
|
||||
|
||||
async load(){
|
||||
this.loading=true;
|
||||
try {
|
||||
const params=new URLSearchParams({page:this.page,search:this.search});
|
||||
if(this.filtroEstado) params.set('estado',this.filtroEstado);
|
||||
const r=await axios.get('/app/contabilidad/cuentas-cobro/list?'+params.toString());
|
||||
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
|
||||
const er=await axios.get('/app/contabilidad/entidades/select');
|
||||
this.entidades=er.data;
|
||||
} finally{ this.loading=false; }
|
||||
},
|
||||
|
||||
openCreate(){ this.editId=null; this.error=''; this.form={entidad_id:'',descripcion:'',valor:0,fecha:'',fecha_vencimiento:'',notas:''}; this.showModal=true; },
|
||||
async save(){
|
||||
this.saving=true; this.error='';
|
||||
try {
|
||||
await axios.post('/app/contabilidad/cuentas-cobro', this.form);
|
||||
this.showModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
marcarPagado(c){ this.pagarId=c.ID; this.pagoFecha=new Date().toISOString().substring(0,10); this.showPagarModal=true; },
|
||||
async doPagar(){
|
||||
this.saving=true;
|
||||
try {
|
||||
await axios.put(`/app/contabilidad/cuentas-cobro/${this.pagarId}`, {estado:'pagado',fecha_pago:this.pagoFecha});
|
||||
this.showPagarModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
confirmDelete(c){ this.deleteId=c.ID; this.showDelete=true; },
|
||||
async doDelete(){
|
||||
this.saving=true;
|
||||
try{ await axios.delete(`/app/contabilidad/cuentas-cobro/${this.deleteId}`); this.showDelete=false; await this.load(); }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
formatoCOP(n){ if(n==null) return '$0'; return '$ '+Number(n).toLocaleString('es-CO',{minimumFractionDigits:0}); },
|
||||
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
<div x-data="pagarApp()" x-init="init()" class="p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Cuentas por pagar</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Facturas y montos pendientes de pago</p>
|
||||
</div>
|
||||
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nuevo pendiente</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar..." class="input-field w-full max-w-xs">
|
||||
<select x-model="filtroEstado" @change="page=1;load()" class="input-field">
|
||||
<option value="">Todos</option>
|
||||
<option value="pendiente">Pendiente</option>
|
||||
<option value="pagado">Pagado</option>
|
||||
<option value="parcial">Parcial</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Fecha</th>
|
||||
<th class="px-4 py-3 text-left">Entidad</th>
|
||||
<th class="px-4 py-3 text-left">Descripción</th>
|
||||
<th class="px-4 py-3 text-right">Valor</th>
|
||||
<th class="px-4 py-3 text-left">Vence</th>
|
||||
<th class="px-4 py-3 text-left">Estado</th>
|
||||
<th class="px-4 py-3 text-left">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-if="loading">
|
||||
<tr><td colspan="7" class="text-center py-10 text-slate-400">Cargando...</td></tr>
|
||||
</template>
|
||||
<template x-for="c in items" :key="c.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.fecha)"></td>
|
||||
<td class="px-4 py-3 text-slate-700 font-medium" x-text="c.entidad?.nombre||'-'"></td>
|
||||
<td class="px-4 py-3 text-slate-600" x-text="c.descripcion"></td>
|
||||
<td class="px-4 py-3 text-right font-semibold text-red-600" x-text="formatoCOP(c.valor)"></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.vencimiento)"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="badge" :class="{'badge-yellow':c.estado==='pendiente','badge-green':c.estado==='pagado','badge-blue':c.estado==='parcial'}" x-text="c.estado"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="marcarPagado(c)" class="btn-icon text-green-500" title="Marcar pagado" x-show="c.estado!=='pagado'">✓</button>
|
||||
<button @click="confirmDelete(c)" class="btn-icon text-red-500">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
|
||||
<span>Total: <strong x-text="total"></strong></span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="page--;load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
|
||||
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
|
||||
<button @click="page++;load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Nuevo pendiente de pago</h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="label">Entidad (a quién debemos)</label>
|
||||
<select x-model.number="form.entidad_id" class="input-field w-full" required>
|
||||
<option value="">Seleccionar...</option>
|
||||
<template x-for="e in entidades" :key="e.ID">
|
||||
<option :value="e.ID" x-text="e.nombre"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Descripción</label>
|
||||
<input x-model="form.descripcion" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Valor ($)</label>
|
||||
<input x-model.number="form.valor" type="number" step="0.01" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Fecha</label>
|
||||
<input x-model="form.fecha" type="date" class="input-field w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Fecha vencimiento</label>
|
||||
<input x-model="form.fecha_vencimiento" type="date" class="input-field w-full">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Notas</label>
|
||||
<textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal pagado -->
|
||||
<div x-show="showPagarModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showPagarModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Marcar como pagado</h2>
|
||||
<form @submit.prevent="doPagar()">
|
||||
<label class="label">Fecha de pago</label>
|
||||
<input x-model="pagoFecha" type="date" class="input-field w-full mb-4" required>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="showPagarModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Confirmar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-2">¿Eliminar?</h2>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function pagarApp() {
|
||||
return {
|
||||
items:[], entidades:[], total:0, totalPages:1, page:1, search:'', filtroEstado:'',
|
||||
loading:false, saving:false, showModal:false, showDelete:false, showPagarModal:false,
|
||||
deleteId:null, pagarId:null, pagoFecha:'', error:'',
|
||||
form:{ entidad_id:'', descripcion:'', valor:0, fecha:'', fecha_vencimiento:'', notas:'' },
|
||||
|
||||
async init(){ await this.load(); },
|
||||
|
||||
async load(){
|
||||
this.loading=true;
|
||||
try {
|
||||
const params=new URLSearchParams({page:this.page,search:this.search});
|
||||
if(this.filtroEstado) params.set('estado',this.filtroEstado);
|
||||
const r=await axios.get('/app/contabilidad/cuentas-pagar/list?'+params.toString());
|
||||
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
|
||||
const er=await axios.get('/app/contabilidad/entidades/select');
|
||||
this.entidades=er.data;
|
||||
} finally{ this.loading=false; }
|
||||
},
|
||||
|
||||
openCreate(){ this.editId=null; this.error=''; this.form={entidad_id:'',descripcion:'',valor:0,fecha:'',fecha_vencimiento:'',notas:''}; this.showModal=true; },
|
||||
async save(){
|
||||
this.saving=true; this.error='';
|
||||
try {
|
||||
await axios.post('/app/contabilidad/cuentas-pagar', this.form);
|
||||
this.showModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
marcarPagado(c){ this.pagarId=c.ID; this.pagoFecha=new Date().toISOString().substring(0,10); this.showPagarModal=true; },
|
||||
async doPagar(){
|
||||
this.saving=true;
|
||||
try {
|
||||
await axios.put(`/app/contabilidad/cuentas-pagar/${this.pagarId}`, {estado:'pagado',fecha_pago:this.pagoFecha});
|
||||
this.showPagarModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
confirmDelete(c){ this.deleteId=c.ID; this.showDelete=true; },
|
||||
async doDelete(){
|
||||
this.saving=true;
|
||||
try{ await axios.delete(`/app/contabilidad/cuentas-pagar/${this.deleteId}`); this.showDelete=false; await this.load(); }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
formatoCOP(n){ if(n==null) return '$0'; return '$ '+Number(n).toLocaleString('es-CO',{minimumFractionDigits:0}); },
|
||||
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<div x-data="entidadesApp()" x-init="init()" class="p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Entidades</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Clientes, proveedores y empresas</p>
|
||||
</div>
|
||||
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nueva entidad</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar por nombre, documento..." class="input-field w-full max-w-sm">
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Nombre</th>
|
||||
<th class="px-4 py-3 text-left">Tipo</th>
|
||||
<th class="px-4 py-3 text-left">Documento</th>
|
||||
<th class="px-4 py-3 text-left">Email</th>
|
||||
<th class="px-4 py-3 text-left">Teléfono</th>
|
||||
<th class="px-4 py-3 text-left">Contacto</th>
|
||||
<th class="px-4 py-3 text-left">Activo</th>
|
||||
<th class="px-4 py-3 text-left">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-if="loading">
|
||||
<tr><td colspan="8" class="text-center py-10 text-slate-400">Cargando...</td></tr>
|
||||
</template>
|
||||
<template x-for="e in items" :key="e.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 text-slate-700 font-medium" x-text="e.nombre"></td>
|
||||
<td class="px-4 py-3"><span class="badge" :class="{'badge-green':e.tipo==='cliente','badge-blue':e.tipo==='proveedor','badge-slate':e.tipo==='ambos'}" x-text="e.tipo||'-'"></span></td>
|
||||
<td class="px-4 py-3 text-slate-600 text-xs font-mono" x-text="e.documento||'-'"></td>
|
||||
<td class="px-4 py-3 text-slate-600" x-text="e.email||'-'"></td>
|
||||
<td class="px-4 py-3 text-slate-600" x-text="e.telefono||'-'"></td>
|
||||
<td class="px-4 py-3 text-slate-600" x-text="e.contacto||'-'"></td>
|
||||
<td class="px-4 py-3"><span x-show="e.activo" class="text-green-500">Activo</span><span x-show="!e.activo" class="text-red-400">Inactivo</span></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEdit(e)" class="btn-icon text-yellow-500">✏️</button>
|
||||
<button @click="confirmDelete(e)" class="btn-icon text-red-500">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
|
||||
<span>Total: <strong x-text="total"></strong></span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="page--;load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
|
||||
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
|
||||
<button @click="page++;load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4" x-text="editId?'Editar entidad':'Nueva entidad'"></h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="label">Nombre</label>
|
||||
<input x-model="form.nombre" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Tipo</label>
|
||||
<select x-model="form.tipo" class="input-field w-full">
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="cliente">Cliente</option>
|
||||
<option value="proveedor">Proveedor</option>
|
||||
<option value="ambos">Ambos</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Documento/NIT</label>
|
||||
<input x-model="form.documento" class="input-field w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input x-model="form.email" type="email" class="input-field w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Teléfono</label>
|
||||
<input x-model="form.telefono" class="input-field w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contacto persona</label>
|
||||
<input x-model="form.contacto" class="input-field w-full">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Notas</label>
|
||||
<textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-2">
|
||||
<input type="checkbox" x-model="form.activo" id="ea" class="w-4 h-4">
|
||||
<label for="ea" class="text-sm text-slate-600">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-2">¿Eliminar entidad?</h2>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function entidadesApp() {
|
||||
return {
|
||||
items:[], total:0, totalPages:1, page:1, search:'',
|
||||
loading:false, saving:false, showModal:false, showDelete:false,
|
||||
editId:null, deleteId:null, error:'',
|
||||
form:{ nombre:'', tipo:'', documento:'', email:'', telefono:'', contacto:'', notas:'', activo:true },
|
||||
|
||||
async init(){ await this.load(); },
|
||||
|
||||
async load(){
|
||||
this.loading=true;
|
||||
try {
|
||||
const r=await axios.get(`/app/contabilidad/entidades?page=${this.page}&search=${encodeURIComponent(this.search)}`);
|
||||
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
|
||||
} finally{ this.loading=false; }
|
||||
},
|
||||
|
||||
openCreate(){ this.editId=null; this.error=''; this.form={nombre:'',tipo:'',documento:'',email:'',telefono:'',contacto:'',notas:'',activo:true}; this.showModal=true; },
|
||||
openEdit(e){
|
||||
this.editId=e.ID; this.error='';
|
||||
this.form={nombre:e.nombre,tipo:e.tipo||'',documento:e.documento||'',email:e.email||'',telefono:e.telefono||'',contacto:e.contacto||'',notas:e.notas||'',activo:e.activo};
|
||||
this.showModal=true;
|
||||
},
|
||||
async save(){
|
||||
this.saving=true; this.error='';
|
||||
try {
|
||||
if(this.editId) await axios.put(`/app/contabilidad/entidades/${this.editId}`, this.form);
|
||||
else await axios.post('/app/contabilidad/entidades', this.form);
|
||||
this.showModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
confirmDelete(e){ this.deleteId=e.ID; this.showDelete=true; },
|
||||
async doDelete(){
|
||||
this.saving=true;
|
||||
try{ await axios.delete(`/app/contabilidad/entidades/${this.deleteId}`); this.showDelete=false; await this.load(); }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,244 @@
|
||||
<div x-data="transaccionesApp()" x-init="init()" class="p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Transacciones</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Registro de ingresos y egresos</p>
|
||||
</div>
|
||||
<button @click="openCreate()" class="btn-primary flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
|
||||
Nueva transacción
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar..." class="input-field w-full max-w-xs">
|
||||
<select x-model="filtroTipo" @change="page=1;load()" class="input-field">
|
||||
<option value="">Todos</option>
|
||||
<option value="ingreso">Ingresos</option>
|
||||
<option value="egreso">Egresos</option>
|
||||
</select>
|
||||
<select x-model="filtroMes" @change="page=1;load()" class="input-field">
|
||||
<option value="0">Todos los meses</option>
|
||||
<template x-for="(m,i) in meses" :key="i+1">
|
||||
<option :value="i+1" x-text="m"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Fecha</th>
|
||||
<th class="px-4 py-3 text-left">Tipo</th>
|
||||
<th class="px-4 py-3 text-left">Descripción</th>
|
||||
<th class="px-4 py-3 text-left">Categoría</th>
|
||||
<th class="px-4 py-3 text-left">Entidad</th>
|
||||
<th class="px-4 py-3 text-right">Valor</th>
|
||||
<th class="px-4 py-3 text-left">Pago</th>
|
||||
<th class="px-4 py-3 text-left">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-if="loading">
|
||||
<tr><td colspan="8" class="text-center py-10 text-slate-400">Cargando...</td></tr>
|
||||
</template>
|
||||
<template x-for="t in items" :key="t.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(t.fecha)"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="badge" :class="t.tipo==='ingreso'?'badge-green':'badge-red'" x-text="t.tipo"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700 max-w-xs truncate" x-text="t.descripcion"></td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full mr-1" :style="'background:'+(t.cuenta?.color||'#ccc')"></span>
|
||||
<span x-text="t.cuenta?.nombre||'-'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600" x-text="t.entidad?.nombre||'-'"></td>
|
||||
<td class="px-4 py-3 text-right font-semibold" :class="t.tipo==='ingreso'?'text-green-600':'text-red-600'" x-text="formatoCOP(t.valor)"></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500" x-text="t.forma_pago||'-'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEdit(t)" class="btn-icon text-yellow-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(t)" class="btn-icon text-red-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
|
||||
<span>Total: <strong x-text="total"></strong></span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="page--;load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
|
||||
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
|
||||
<button @click="page++;load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear/editar -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 max-h-screen overflow-y-auto">
|
||||
<h2 class="text-lg font-bold mb-4" x-text="editId ? 'Editar transacción' : 'Nueva transacción'"></h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="label">Tipo</label>
|
||||
<select x-model="form.tipo" class="input-field w-full" required>
|
||||
<option value="ingreso">Ingreso</option>
|
||||
<option value="egreso">Egreso</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Descripción</label>
|
||||
<input x-model="form.descripcion" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Valor ($)</label>
|
||||
<input x-model.number="form.valor" type="number" step="0.01" class="input-field w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Fecha</label>
|
||||
<input x-model="form.fecha" type="date" class="input-field w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Categoría</label>
|
||||
<select x-model.number="form.cuenta_id" class="input-field w-full">
|
||||
<option value="">Seleccionar...</option>
|
||||
<template x-for="c in cuentas" :key="c.ID">
|
||||
<option :value="c.ID" x-text="c.nombre"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Entidad</label>
|
||||
<select x-model.number="form.entidad_id" class="input-field w-full">
|
||||
<option value="">Seleccionar...</option>
|
||||
<template x-for="e in entidades" :key="e.ID">
|
||||
<option :value="e.ID" x-text="e.nombre"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Forma de pago</label>
|
||||
<select x-model="form.forma_pago" class="input-field w-full">
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
<option value="cheque">Cheque</option>
|
||||
<option value="otro">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Estado</label>
|
||||
<select x-model="form.estado" class="input-field w-full">
|
||||
<option value="registrada">Registrada</option>
|
||||
<option value="conciliada">Conciliada</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Notas</label>
|
||||
<textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal eliminar -->
|
||||
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-2">¿Eliminar transacción?</h2>
|
||||
<p class="text-slate-500 text-sm mb-5">Esta acción no se puede deshacer.</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function transaccionesApp() {
|
||||
return {
|
||||
items:[], cuentas:[], entidades:[], total:0, totalPages:1, page:1, search:'',
|
||||
filtroTipo:'', filtroMes:0,
|
||||
loading:false, saving:false, showModal:false, showDelete:false,
|
||||
editId:null, deleteId:null, error:'',
|
||||
meses:['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'],
|
||||
form:{ tipo:'ingreso', descripcion:'', valor:0, fecha:'', cuenta_id:'', entidad_id:'', forma_pago:'', estado:'registrada', notas:'' },
|
||||
|
||||
async init(){ await this.load(); await this.loadSelects(); },
|
||||
|
||||
async load(){
|
||||
this.loading=true;
|
||||
try {
|
||||
const params = new URLSearchParams({page:this.page, search:this.search});
|
||||
if(this.filtroTipo) params.set('tipo', this.filtroTipo);
|
||||
if(this.filtroMes) { params.set('mes', this.filtroMes); params.set('anio', new Date().getFullYear()); }
|
||||
const r=await axios.get('/app/contabilidad/transacciones/list?'+params.toString());
|
||||
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
|
||||
} finally{ this.loading=false; }
|
||||
},
|
||||
|
||||
async loadSelects(){
|
||||
try {
|
||||
const [cr, er] = await Promise.all([
|
||||
axios.get('/app/contabilidad/cuentas/select'),
|
||||
axios.get('/app/contabilidad/entidades/select')
|
||||
]);
|
||||
this.cuentas=cr.data; this.entidades=er.data;
|
||||
} catch(e){}
|
||||
},
|
||||
|
||||
openCreate(){
|
||||
this.editId=null; this.error='';
|
||||
this.form={tipo:'ingreso',descripcion:'',valor:0,fecha:'',cuenta_id:'',entidad_id:'',forma_pago:'',estado:'registrada',notas:''};
|
||||
this.showModal=true;
|
||||
},
|
||||
openEdit(t){
|
||||
this.editId=t.ID; this.error='';
|
||||
const f=t.fecha?t.fecha.substring(0,10):'';
|
||||
this.form={tipo:t.tipo,descripcion:t.descripcion,valor:t.valor,fecha:f,cuenta_id:t.cuenta_id||'',entidad_id:t.entidad_id||'',forma_pago:t.forma_pago||'',estado:t.estado||'registrada',notas:t.notas||''};
|
||||
this.showModal=true;
|
||||
},
|
||||
async save(){
|
||||
this.saving=true; this.error='';
|
||||
const payload={...this.form};
|
||||
try {
|
||||
if(this.editId) await axios.put(`/app/contabilidad/transacciones/${this.editId}`, payload);
|
||||
else await axios.post('/app/contabilidad/transacciones', payload);
|
||||
this.showModal=false; await this.load();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
confirmDelete(t){ this.deleteId=t.ID; this.showDelete=true; },
|
||||
async doDelete(){
|
||||
this.saving=true;
|
||||
try{ await axios.delete(`/app/contabilidad/transacciones/${this.deleteId}`); this.showDelete=false; await this.load(); }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
formatoCOP(n){ if(n==null) return '$0'; return '$ '+Number(n).toLocaleString('es-CO',{minimumFractionDigits:0}); },
|
||||
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<div x-data="websmsApp()" x-init="init()" class="p-6 max-w-3xl mx-auto">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">WebSMS (LabsMobile)</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Integración de SMS para notificaciones</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Configuración API</h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="label">Usuario (email)</label>
|
||||
<input x-model="form.username" class="input-field w-full" required placeholder="lizandro.guarnizo@u-site.app">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">API Token</label>
|
||||
<div class="relative">
|
||||
<input x-model="form.api_token" type="password" class="input-field w-full pr-10" required>
|
||||
<button type="button" @click="showToken=!showToken; $el.previousElementSibling.type=showToken?'text':'password'" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Remitente (TPOA)</label>
|
||||
<input x-model="form.sender" class="input-field w-full" placeholder="U-SITE">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Notas</label>
|
||||
<input x-model="form.notas" class="input-field w-full" placeholder="Opcional">
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<div class="flex justify-end mt-5">
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar configuración'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Probar envío -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Probar envío</h2>
|
||||
<form @submit.prevent="test()">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Número destino</label>
|
||||
<input x-model="testPara" class="input-field w-full" placeholder="57300xxxxxx" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Mensaje (opcional)</label>
|
||||
<input x-model="testMsg" class="input-field w-full" placeholder="Texto de prueba">
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="testResult" x-text="testResult" class="text-sm mt-3" :class="testOk?'text-green-600':'text-red-500'"></p>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Enviando...':'Enviar SMS de prueba'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Webhooks URLs -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Webhooks</h2>
|
||||
<p class="text-sm text-slate-500 mb-4">Configura estas URLs en el panel de WebSMS para recibir notificaciones:</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="label">Confirmaciones de entrega (ACK)</label>
|
||||
<code class="block text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all">https://admin.u-site.app/webhooks/websms/delivery</code>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Clics en shortlinks</label>
|
||||
<code class="block text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all">https://admin.u-site.app/webhooks/websms/click</code>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Mensajes entrantes</label>
|
||||
<code class="block text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all">https://admin.u-site.app/webhooks/websms/incoming</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Últimos envíos</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Fecha</th>
|
||||
<th class="px-3 py-2 text-left">Para</th>
|
||||
<th class="px-3 py-2 text-left">Mensaje</th>
|
||||
<th class="px-3 py-2 text-left">Estado</th>
|
||||
<th class="px-3 py-2 text-left">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-for="l in logs" :key="l.ID">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-3 py-2 text-xs text-slate-500" x-text="formatDate(l.CreatedAt)"></td>
|
||||
<td class="px-3 py-2 text-slate-700" x-text="l.para"></td>
|
||||
<td class="px-3 py-2 text-slate-600 max-w-xs truncate" x-text="l.mensaje"></td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge" :class="l.status==='0'?'badge-green':'badge-red'" x-text="l.status"></span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs font-mono text-slate-500" x-text="l.msg_id||'-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!logs.length">
|
||||
<tr><td colspan="5" class="text-center py-6 text-slate-400">Sin envíos aún</td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function websmsApp() {
|
||||
return {
|
||||
form:{ username:'', api_token:'', sender:'', notas:'' },
|
||||
showToken:false,
|
||||
logs:[],
|
||||
testPara:'', testMsg:'', testResult:'', testOk:false,
|
||||
saving:false, error:'',
|
||||
|
||||
async init(){
|
||||
await this.loadConfig();
|
||||
await this.loadLogs();
|
||||
},
|
||||
|
||||
async loadConfig(){
|
||||
try {
|
||||
const r=await axios.get('/app/websms/config');
|
||||
if(r.data.data){
|
||||
this.form={id:r.data.data.ID, username:r.data.data.username, api_token:r.data.data.api_token, sender:r.data.data.sender||'', notas:r.data.data.notas||''};
|
||||
}
|
||||
} catch(e){}
|
||||
},
|
||||
|
||||
async loadLogs(){
|
||||
try { const r=await axios.get('/app/websms/logs'); this.logs=r.data; } catch(e){}
|
||||
},
|
||||
|
||||
async save(){
|
||||
this.saving=true; this.error='';
|
||||
try {
|
||||
await axios.post('/app/websms/save', this.form);
|
||||
await this.loadConfig();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
async test(){
|
||||
this.saving=true; this.testResult=''; this.testOk=false;
|
||||
try {
|
||||
const r=await axios.post('/app/websms/test', {para:this.testPara, mensaje:this.testMsg});
|
||||
this.testResult='SMS enviado correctamente. Código: '+r.data.response.code;
|
||||
this.testOk=true;
|
||||
await this.loadLogs();
|
||||
} catch(e){ this.testResult=e.response?.data?.error||'Error al enviar'; this.testOk=false; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}); },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,603 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// ─── Render de vistas ────────────────────────────────────────────────────────
|
||||
|
||||
func ContabilidadIndex(c *fiber.Ctx) error {
|
||||
return c.Render("contabilidad/contabilidad", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func ContabilidadTransaccionesView(c *fiber.Ctx) error {
|
||||
return c.Render("contabilidad/transacciones", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func ContabilidadCuentasView(c *fiber.Ctx) error {
|
||||
return c.Render("contabilidad/cuentas", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func ContabilidadEntidadesView(c *fiber.Ctx) error {
|
||||
return c.Render("contabilidad/entidades", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func ContabilidadCobroView(c *fiber.Ctx) error {
|
||||
return c.Render("contabilidad/cuentas_cobro", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func ContabilidadPagarView(c *fiber.Ctx) error {
|
||||
return c.Render("contabilidad/cuentas_pagar", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
func ContabilidadDashboard(c *fiber.Ctx) error {
|
||||
now := time.Now()
|
||||
mes, _ := strconv.Atoi(c.Query("mes", strconv.Itoa(int(now.Month()))))
|
||||
anio, _ := strconv.Atoi(c.Query("anio", strconv.Itoa(now.Year())))
|
||||
if mes < 1 || mes > 12 {
|
||||
mes = int(now.Month())
|
||||
}
|
||||
if anio < 2000 {
|
||||
anio = now.Year()
|
||||
}
|
||||
data, err := models.GetDashboardData(mes, anio)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(data)
|
||||
}
|
||||
|
||||
func ContabilidadConsolidado(ctx *fiber.Ctx) error {
|
||||
now := time.Now()
|
||||
mes, _ := strconv.Atoi(ctx.Query("mes", strconv.Itoa(int(now.Month()))))
|
||||
anio, _ := strconv.Atoi(ctx.Query("anio", strconv.Itoa(now.Year())))
|
||||
if mes < 1 || mes > 12 {
|
||||
mes = int(now.Month())
|
||||
}
|
||||
if anio < 2000 {
|
||||
anio = now.Year()
|
||||
}
|
||||
data, err := models.CalcularYGuardarConsolidado(mes, anio)
|
||||
if err != nil {
|
||||
return ctx.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return ctx.JSON(data)
|
||||
}
|
||||
|
||||
func ContabilidadListConsolidados(c *fiber.Ctx) error {
|
||||
anio, _ := strconv.Atoi(c.Query("anio", "0"))
|
||||
items, err := models.ListConsolidados(anio)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Cuentas ──────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetCuentas(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
search := c.Query("search", "")
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 50
|
||||
offset := (page - 1) * limit
|
||||
items, total, err := models.GetAllCuentas(limit, offset, search)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
func GetCuentasSelect(c *fiber.Ctx) error {
|
||||
items, err := models.GetAllCuentasSelect()
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
func CreateCuenta(c *fiber.Ctx) error {
|
||||
var req models.Cuenta
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if req.Nombre == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "nombre es requerido"})
|
||||
}
|
||||
if req.Tipo == "" {
|
||||
req.Tipo = "egreso"
|
||||
}
|
||||
req.Activo = true
|
||||
if err := models.CreateCuenta(&req); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(req)
|
||||
}
|
||||
|
||||
func UpdateCuenta(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 req models.Cuenta
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
req.ID = uint(id)
|
||||
if err := models.UpdateCuenta(&req); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteCuenta(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.DeleteCuenta(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Entidades ────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetEntidades(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
search := c.Query("search", "")
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
items, total, err := models.GetAllEntidades(limit, offset, search)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
func GetEntidadesSelect(c *fiber.Ctx) error {
|
||||
items, err := models.GetAllEntidadesSelect()
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
func CreateEntidad(c *fiber.Ctx) error {
|
||||
var req models.Entidad
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if req.Nombre == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "nombre es requerido"})
|
||||
}
|
||||
req.Activo = true
|
||||
if err := models.CreateEntidad(&req); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(req)
|
||||
}
|
||||
|
||||
func UpdateEntidad(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 req models.Entidad
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
req.ID = uint(id)
|
||||
if err := models.UpdateEntidad(&req); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteEntidad(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.DeleteEntidad(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Transacciones ────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetTransacciones(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
search := c.Query("search", "")
|
||||
filtroTipo := c.Query("tipo", "")
|
||||
mes, _ := strconv.Atoi(c.Query("mes", "0"))
|
||||
anio, _ := strconv.Atoi(c.Query("anio", "0"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 30
|
||||
offset := (page - 1) * limit
|
||||
items, total, err := models.GetAllTransacciones(limit, offset, search, filtroTipo, mes, anio)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateTransaccion(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
Fecha string `json:"fecha"`
|
||||
Tipo string `json:"tipo"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Valor float64 `json:"valor"`
|
||||
CuentaID *uint `json:"cuenta_id"`
|
||||
EntidadID *uint `json:"entidad_id"`
|
||||
FormaPago string `json:"forma_pago"`
|
||||
Estado string `json:"estado"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if req.Descripcion == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "descripcion es requerida"})
|
||||
}
|
||||
if req.Valor <= 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "valor debe ser mayor a 0"})
|
||||
}
|
||||
t := &models.Transaccion{
|
||||
Tipo: req.Tipo,
|
||||
Descripcion: req.Descripcion,
|
||||
Valor: req.Valor,
|
||||
CuentaID: req.CuentaID,
|
||||
EntidadID: req.EntidadID,
|
||||
FormaPago: req.FormaPago,
|
||||
Estado: req.Estado,
|
||||
Notas: req.Notas,
|
||||
Fecha: time.Now(),
|
||||
}
|
||||
if t.Tipo == "" {
|
||||
t.Tipo = "ingreso"
|
||||
}
|
||||
if t.Estado == "" {
|
||||
t.Estado = "registrada"
|
||||
}
|
||||
if req.Fecha != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil {
|
||||
t.Fecha = parsed
|
||||
}
|
||||
}
|
||||
if err := models.CreateTransaccion(t); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(t)
|
||||
}
|
||||
|
||||
func UpdateTransaccion(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 Req struct {
|
||||
Fecha string `json:"fecha"`
|
||||
Tipo string `json:"tipo"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Valor float64 `json:"valor"`
|
||||
CuentaID *uint `json:"cuenta_id"`
|
||||
EntidadID *uint `json:"entidad_id"`
|
||||
FormaPago string `json:"forma_pago"`
|
||||
Estado string `json:"estado"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
t := &models.Transaccion{
|
||||
Tipo: req.Tipo,
|
||||
Descripcion: req.Descripcion,
|
||||
Valor: req.Valor,
|
||||
CuentaID: req.CuentaID,
|
||||
EntidadID: req.EntidadID,
|
||||
FormaPago: req.FormaPago,
|
||||
Estado: req.Estado,
|
||||
Notas: req.Notas,
|
||||
Fecha: time.Now(),
|
||||
}
|
||||
t.ID = uint(id)
|
||||
if req.Fecha != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil {
|
||||
t.Fecha = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if err := models.UpdateTransaccion(t); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteTransaccion(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.DeleteTransaccion(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Cuentas por Cobrar ──────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetCuentasCobro(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
search := c.Query("search", "")
|
||||
estado := c.Query("estado", "")
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
items, total, err := models.GetAllCuentasCobro(limit, offset, search, estado)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateCuentaCobro(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
EntidadID uint `json:"entidad_id"`
|
||||
Fecha string `json:"fecha"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Valor float64 `json:"valor"`
|
||||
FechaVencimiento string `json:"fecha_vencimiento"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if req.EntidadID == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "entidad_id es requerido"})
|
||||
}
|
||||
cc := &models.CuentaCobro{
|
||||
EntidadID: req.EntidadID,
|
||||
Descripcion: req.Descripcion,
|
||||
Valor: req.Valor,
|
||||
Estado: "pendiente",
|
||||
Notas: req.Notas,
|
||||
Fecha: time.Now(),
|
||||
}
|
||||
if req.Fecha != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil {
|
||||
cc.Fecha = parsed
|
||||
}
|
||||
}
|
||||
if req.FechaVencimiento != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.FechaVencimiento); err == nil {
|
||||
cc.FechaVencimiento = &parsed
|
||||
}
|
||||
}
|
||||
if err := models.CreateCuentaCobro(cc); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(cc)
|
||||
}
|
||||
|
||||
func UpdateCuentaCobro(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 Req struct {
|
||||
Estado string `json:"estado"`
|
||||
FechaPago string `json:"fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id"`
|
||||
Notas string `json:"notas"`
|
||||
FechaVencimiento string `json:"fecha_vencimiento"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Valor float64 `json:"valor"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
cc := &models.CuentaCobro{}
|
||||
cc.ID = uint(id)
|
||||
if req.Estado != "" {
|
||||
cc.Estado = req.Estado
|
||||
}
|
||||
if req.FechaPago != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.FechaPago); err == nil {
|
||||
cc.FechaPago = &parsed
|
||||
}
|
||||
}
|
||||
cc.TransaccionID = req.TransaccionID
|
||||
cc.Notas = req.Notas
|
||||
if err := models.UpdateCuentaCobro(cc); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteCuentaCobro(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.DeleteCuentaCobro(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ─── CRUD: Cuentas por Pagar ────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetCuentasPagar(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
search := c.Query("search", "")
|
||||
estado := c.Query("estado", "")
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
items, total, err := models.GetAllCuentasPagar(limit, offset, search, estado)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateCuentaPagar(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
EntidadID uint `json:"entidad_id"`
|
||||
Fecha string `json:"fecha"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Valor float64 `json:"valor"`
|
||||
Vencimiento string `json:"vencimiento"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if req.EntidadID == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "entidad_id es requerido"})
|
||||
}
|
||||
cp := &models.CuentaPagar{
|
||||
EntidadID: req.EntidadID,
|
||||
Descripcion: req.Descripcion,
|
||||
Valor: req.Valor,
|
||||
Estado: "pendiente",
|
||||
Notas: req.Notas,
|
||||
Fecha: time.Now(),
|
||||
}
|
||||
if req.Fecha != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil {
|
||||
cp.Fecha = parsed
|
||||
}
|
||||
}
|
||||
if req.Vencimiento != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.Vencimiento); err == nil {
|
||||
cp.Vencimiento = &parsed
|
||||
}
|
||||
}
|
||||
if err := models.CreateCuentaPagar(cp); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(cp)
|
||||
}
|
||||
|
||||
func UpdateCuentaPagar(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 Req struct {
|
||||
Estado string `json:"estado"`
|
||||
FechaPago string `json:"fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
cp := &models.CuentaPagar{}
|
||||
cp.ID = uint(id)
|
||||
if req.Estado != "" {
|
||||
cp.Estado = req.Estado
|
||||
}
|
||||
if req.FechaPago != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.FechaPago); err == nil {
|
||||
cp.FechaPago = &parsed
|
||||
}
|
||||
}
|
||||
cp.TransaccionID = req.TransaccionID
|
||||
cp.Notas = req.Notas
|
||||
if err := models.UpdateCuentaPagar(cp); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteCuentaPagar(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.DeleteCuentaPagar(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// ─── Vistas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func WebSmsConfigPage(c *fiber.Ctx) error {
|
||||
cfg, _ := models.GetWebSmsConfig()
|
||||
return c.Render("websms_config", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
"cfg": cfg,
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetWebSmsConfig(c *fiber.Ctx) error {
|
||||
cfg, err := models.GetWebSmsConfig()
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"data": nil})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": cfg})
|
||||
}
|
||||
|
||||
func SaveWebSmsConfig(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ApiToken string `json:"api_token"`
|
||||
Sender string `json:"sender"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if b.Username == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "username es requerido"})
|
||||
}
|
||||
if b.ApiToken == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "api_token es requerido"})
|
||||
}
|
||||
|
||||
cfg := models.WebSmsConfig{
|
||||
Username: b.Username,
|
||||
ApiToken: b.ApiToken,
|
||||
Sender: b.Sender,
|
||||
Notas: b.Notas,
|
||||
}
|
||||
cfg.ID = b.ID
|
||||
|
||||
if err := models.SaveWebSmsConfig(cfg); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Configuración WebSMS guardada"})
|
||||
}
|
||||
|
||||
func TestWebSms(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
Para string `json:"para"`
|
||||
Mensaje string `json:"mensaje"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if b.Para == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "número de destino requerido"})
|
||||
}
|
||||
if b.Mensaje == "" {
|
||||
b.Mensaje = "[TEST] Notificación desde U-site vía WebSMS"
|
||||
}
|
||||
|
||||
cfg, err := models.GetWebSmsConfig()
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "WebSMS no configurado"})
|
||||
}
|
||||
|
||||
resp, err := services.SendWebSms(cfg, b.Para, b.Mensaje)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
models.CreateWebSmsLog(&models.WebSmsLog{
|
||||
Para: b.Para,
|
||||
Mensaje: b.Mensaje,
|
||||
Status: resp.Code,
|
||||
MsgID: resp.ID,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "response": resp})
|
||||
}
|
||||
|
||||
func GetWebSmsLogs(c *fiber.Ctx) error {
|
||||
logs, err := models.GetWebSmsLogs(50)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(logs)
|
||||
}
|
||||
|
||||
func GetWebSmsWebhookLogs(c *fiber.Ctx) error {
|
||||
logs, err := models.GetWebSmsWebhookLogs(50)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(logs)
|
||||
}
|
||||
|
||||
// ─── Webhooks entrantes ─────────────────────────────────────────────────────
|
||||
|
||||
func WebSmsDeliveryWebhook(c *fiber.Ctx) error {
|
||||
var payload services.WebSmsAckPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
raw := c.Body()
|
||||
models.CreateWebSmsWebhookLog(&models.WebSmsWebhookLog{
|
||||
Tipo: "delivery",
|
||||
MsgID: payload.ID,
|
||||
Para: payload.Msisdn,
|
||||
Status: payload.Status,
|
||||
Raw: string(raw),
|
||||
})
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
|
||||
func WebSmsClickWebhook(c *fiber.Ctx) error {
|
||||
var payload services.WebSmsClickPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
raw := c.Body()
|
||||
models.CreateWebSmsWebhookLog(&models.WebSmsWebhookLog{
|
||||
Tipo: "click",
|
||||
MsgID: payload.ID,
|
||||
Para: payload.Msisdn,
|
||||
Status: "clicked",
|
||||
Raw: string(raw),
|
||||
})
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
|
||||
func WebSmsIncomingWebhook(c *fiber.Ctx) error {
|
||||
var payload services.WebSmsIncomingPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
raw := c.Body()
|
||||
models.CreateWebSmsWebhookLog(&models.WebSmsWebhookLog{
|
||||
Tipo: "incoming",
|
||||
MsgID: payload.ID,
|
||||
Para: payload.Msisdn,
|
||||
Status: "received",
|
||||
Raw: string(raw),
|
||||
})
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
@@ -37,6 +37,11 @@ func RutasPublicas(web fiber.Router) {
|
||||
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal
|
||||
web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook)
|
||||
|
||||
// ─── WebSMS (LabsMobile) — ACK de entrega, clics y mensajes entrantes ──
|
||||
web.Post("/webhooks/websms/delivery", controllers.WebSmsDeliveryWebhook)
|
||||
web.Post("/webhooks/websms/click", controllers.WebSmsClickWebhook)
|
||||
web.Post("/webhooks/websms/incoming", controllers.WebSmsIncomingWebhook)
|
||||
|
||||
// ─── Agente de monitoreo de servidores ────────────────────────────────────
|
||||
// El agente instalado en cada servidor reporta métricas aquí (sin sesión, auth por token).
|
||||
web.Post("/agent/heartbeat", controllers.AgentHeartbeat)
|
||||
|
||||
@@ -453,6 +453,14 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida)
|
||||
protected.Post("/mis-notifs/marcar-todas", controllers.MarcarTodasLeidas)
|
||||
|
||||
// ─── WebSMS (LabsMobile) ───────────────────────────────────────────────────
|
||||
protected.Get("/websms", middlewares.MenuMiddleware, controllers.WebSmsConfigPage)
|
||||
protected.Get("/websms/config", controllers.GetWebSmsConfig)
|
||||
protected.Post("/websms/save", controllers.SaveWebSmsConfig)
|
||||
protected.Post("/websms/test", controllers.TestWebSms)
|
||||
protected.Get("/websms/logs", controllers.GetWebSmsLogs)
|
||||
protected.Get("/websms/webhook-logs", controllers.GetWebSmsWebhookLogs)
|
||||
|
||||
// ─── Planes dLocal (gestión desde panel protegido) ────────────────────────
|
||||
protected.Get("/dlocal/planes", apiControllers.SeePlanes)
|
||||
protected.Post("/dlocal/planes", apiControllers.CreatePlan)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- ============================================================
|
||||
-- Seed: Migrar de Alibaba OSS a MinIO S3
|
||||
-- Ejecutar en la base de datos PostgreSQL de producción:
|
||||
-- psql -h HOST -U usite -d usite -f seed_minio.sql
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Desactivar configuraciones Alibaba activas
|
||||
UPDATE oss_api
|
||||
SET is_active = false,
|
||||
notes = COALESCE(notes, '') || ' [desactivado ' || NOW() || ']'
|
||||
WHERE provider = 'alibaba'
|
||||
AND is_active = true;
|
||||
|
||||
-- Insertar nueva configuración MinIO S3
|
||||
INSERT INTO oss_api (name, provider, endpoint, access_key_id, access_key_secret, bucket_name, region, is_active, public_url, notes, created_at, updated_at)
|
||||
SELECT 'MinIO - vcardv4',
|
||||
's3',
|
||||
'http://135.181.2.237:9000',
|
||||
'admin',
|
||||
'lsE6TAihW98iaXKDWG+6gGLYAFdyp2iC',
|
||||
'vcard-usite',
|
||||
'us-east-1',
|
||||
true,
|
||||
NULL,
|
||||
'Migrado desde Alibaba OSS el ' || NOW(),
|
||||
NOW(),
|
||||
NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM oss_api
|
||||
WHERE provider = 's3'
|
||||
AND endpoint = 'http://135.181.2.237:9000'
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- Si no se insertó (ya existe), activarla y actualizar credenciales
|
||||
UPDATE oss_api
|
||||
SET is_active = true,
|
||||
access_key_id = 'admin',
|
||||
access_key_secret = 'lsE6TAihW98iaXKDWG+6gGLYAFdyp2iC',
|
||||
bucket_name = 'vcard-usite',
|
||||
region = 'us-east-1',
|
||||
notes = 'Actualizado el ' || NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE provider = 's3'
|
||||
AND endpoint = 'http://135.181.2.237:9000'
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
is_active = false
|
||||
OR access_key_id != 'admin'
|
||||
OR access_key_secret != 'lsE6TAihW98iaXKDWG+6gGLYAFdyp2iC'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Verificación
|
||||
SELECT id, name, provider, endpoint, bucket_name, is_active, notes
|
||||
FROM oss_api
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id DESC;
|
||||
Reference in New Issue
Block a user