up
This commit is contained in:
@@ -72,6 +72,16 @@ func Migrate() {
|
||||
// Telegram
|
||||
&models.TelegramConfig{},
|
||||
&models.TelegramLog{},
|
||||
// Portal de Clientes
|
||||
&models.PortalUser{},
|
||||
&models.PortalAcceso{},
|
||||
&models.Proyecto{},
|
||||
&models.ProyectoFase{},
|
||||
&models.ProyectoAvance{},
|
||||
&models.ProyectoEntregable{},
|
||||
&models.ProyectoTicket{},
|
||||
&models.TicketMensaje{},
|
||||
&models.Factura{},
|
||||
); err != nil {
|
||||
log.Fatalf("Error during main migration: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
const portalSessionKey = "portal_user_id"
|
||||
|
||||
// PortalUser obtiene el usuario portal de la sesión actual.
|
||||
func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) {
|
||||
store := app.Http.Session.Get(c)
|
||||
raw := store.Get(portalSessionKey)
|
||||
if raw == nil {
|
||||
return nil, errors.New("no portal session")
|
||||
}
|
||||
id, ok := raw.(uint)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid portal session")
|
||||
}
|
||||
u, err := models.GetPortalUserByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !u.Activo {
|
||||
return nil, errors.New("portal user inactive")
|
||||
}
|
||||
// Precargar accesos del portal user para que GetClienteIDsForPortalUser funcione
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetPortalSession guarda el portal_user_id en la sesión.
|
||||
func SetPortalSession(c *fiber.Ctx, userID uint) error {
|
||||
store := app.Http.Session.Get(c)
|
||||
store.Set(portalSessionKey, userID)
|
||||
return store.Save()
|
||||
}
|
||||
|
||||
// DestroyPortalSession elimina la sesión del portal.
|
||||
func DestroyPortalSession(c *fiber.Ctx) error {
|
||||
store := app.Http.Session.Get(c)
|
||||
store.Delete(portalSessionKey)
|
||||
return store.Save()
|
||||
}
|
||||
|
||||
// IsPortalLoggedIn verifica si hay sesión de portal activa.
|
||||
func IsPortalLoggedIn(c *fiber.Ctx) bool {
|
||||
store := app.Http.Session.Get(c)
|
||||
return store.Get(portalSessionKey) != nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── Factura ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Factura struct {
|
||||
gorm.Model
|
||||
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
|
||||
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
Proyecto *Proyecto `json:"proyecto" gorm:"foreignKey:ProyectoID"`
|
||||
Numero string `json:"numero" gorm:"column:numero"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Monto float64 `json:"monto" gorm:"column:monto"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;default:'COP'"`
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente|pagada|vencida|cancelada
|
||||
FechaEmision time.Time `json:"fecha_emision" gorm:"column:fecha_emision"`
|
||||
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
OriginalName string `json:"original_name" gorm:"column:original_name"`
|
||||
Visible bool `json:"visible" gorm:"column:visible;default:true"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
}
|
||||
|
||||
func (Factura) TableName() string { return "facturas" }
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetAllFacturas(limit, offset int, search string) ([]Factura, int64, error) {
|
||||
var items []Factura
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Factura{}).Preload("Cliente").Preload("Proyecto")
|
||||
if search != "" {
|
||||
db = db.Joins("JOIN clientes ON clientes.id = facturas.cliente_id").
|
||||
Where("facturas.numero ILIKE ? OR clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?",
|
||||
"%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("facturas.fecha_emision DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetFacturasByCliente(clienteID uint, soloVisibles bool) ([]Factura, error) {
|
||||
var items []Factura
|
||||
db := app.Http.Database.DB.Where("cliente_id = ?", clienteID)
|
||||
if soloVisibles {
|
||||
db = db.Where("visible = true")
|
||||
}
|
||||
err := db.Preload("Proyecto").Order("fecha_emision DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetFacturaByID(id uint) (*Factura, error) {
|
||||
var item Factura
|
||||
err := app.Http.Database.DB.Preload("Cliente").Preload("Proyecto").First(&item, id).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func CreateFactura(f *Factura) error {
|
||||
return app.Http.Database.DB.Create(f).Error
|
||||
}
|
||||
|
||||
func UpdateFactura(f *Factura) error {
|
||||
return app.Http.Database.DB.Model(&Factura{}).Where("id = ?", f.ID).Updates(map[string]interface{}{
|
||||
"cliente_id": f.ClienteID,
|
||||
"proyecto_id": f.ProyectoID,
|
||||
"numero": f.Numero,
|
||||
"descripcion": f.Descripcion,
|
||||
"monto": f.Monto,
|
||||
"moneda": f.Moneda,
|
||||
"estado": f.Estado,
|
||||
"fecha_emision": f.FechaEmision,
|
||||
"fecha_vencimiento": f.FechaVencimiento,
|
||||
"visible": f.Visible,
|
||||
"notas": f.Notas,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteFactura(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Factura{}, id).Error
|
||||
}
|
||||
|
||||
func UpdateFacturaArchivo(id uint, archivo, originalName string) error {
|
||||
return app.Http.Database.DB.Model(&Factura{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"archivo": archivo,
|
||||
"original_name": originalName,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── PortalUser ───────────────────────────────────────────────────────────────
|
||||
|
||||
// PortalUser representa un usuario del portal de clientes (separado del admin).
|
||||
// - Rol "cliente": accede a los proyectos de su ClienteID.
|
||||
// - Rol "partner": accede a los proyectos de todos sus ClienteIDs via PortalAccesos.
|
||||
type PortalUser struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
||||
Email string `json:"email" gorm:"column:email;uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"column:password;type:text"`
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"` // nil si es partner
|
||||
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||
Rol string `json:"rol" gorm:"column:rol;default:'cliente'"` // cliente|partner
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"`
|
||||
}
|
||||
|
||||
func (PortalUser) TableName() string { return "portal_users" }
|
||||
|
||||
// ─── PortalAcceso (partners → multiple clients) ───────────────────────────────
|
||||
|
||||
type PortalAcceso struct {
|
||||
gorm.Model
|
||||
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||
}
|
||||
|
||||
func (PortalAcceso) TableName() string { return "portal_accesos" }
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetAllPortalUsers(limit, offset int) ([]PortalUser, int64, error) {
|
||||
var items []PortalUser
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&PortalUser{}).Preload("Cliente").Preload("PortalAccesos.Cliente")
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetPortalUserByID(id uint) (*PortalUser, error) {
|
||||
var item PortalUser
|
||||
err := app.Http.Database.DB.Preload("Cliente").Preload("PortalAccesos.Cliente").First(&item, id).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func GetPortalUserByEmail(email string) (*PortalUser, error) {
|
||||
var item PortalUser
|
||||
err := app.Http.Database.DB.Where("email = ?", email).First(&item).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func CreatePortalUser(u *PortalUser) error {
|
||||
return app.Http.Database.DB.Create(u).Error
|
||||
}
|
||||
|
||||
func UpdatePortalUser(u *PortalUser) error {
|
||||
return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", u.ID).Updates(map[string]interface{}{
|
||||
"nombre": u.Nombre,
|
||||
"email": u.Email,
|
||||
"cliente_id": u.ClienteID,
|
||||
"rol": u.Rol,
|
||||
"activo": u.Activo,
|
||||
"notas": u.Notas,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func UpdatePortalUserPassword(id uint, hashedPassword string) error {
|
||||
return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", id).Update("password", hashedPassword).Error
|
||||
}
|
||||
|
||||
func DeletePortalUser(id uint) error {
|
||||
// Eliminar también sus accesos de partner
|
||||
if err := app.Http.Database.DB.Where("portal_user_id = ?", id).Delete(&PortalAcceso{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Http.Database.DB.Delete(&PortalUser{}, id).Error
|
||||
}
|
||||
|
||||
func AddPortalAcceso(portalUserID, clienteID uint) error {
|
||||
// Evitar duplicados
|
||||
var existing PortalAcceso
|
||||
err := app.Http.Database.DB.Where("portal_user_id = ? AND cliente_id = ?", portalUserID, clienteID).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil // ya existe
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return app.Http.Database.DB.Create(&PortalAcceso{PortalUserID: portalUserID, ClienteID: clienteID}).Error
|
||||
}
|
||||
|
||||
func RemovePortalAcceso(portalUserID, clienteID uint) error {
|
||||
return app.Http.Database.DB.Where("portal_user_id = ? AND cliente_id = ?", portalUserID, clienteID).
|
||||
Delete(&PortalAcceso{}).Error
|
||||
}
|
||||
|
||||
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
||||
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
||||
if u.Rol == "partner" {
|
||||
ids := make([]uint, 0, len(u.PortalAccesos))
|
||||
for _, a := range u.PortalAccesos {
|
||||
ids = append(ids, a.ClienteID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
if u.ClienteID != nil {
|
||||
return []uint{*u.ClienteID}
|
||||
}
|
||||
return []uint{}
|
||||
}
|
||||
|
||||
// CheckPortalLogin valida credenciales y retorna el portal user.
|
||||
func CheckPortalLogin(email, password string) (*PortalUser, error) {
|
||||
u, err := GetPortalUserByEmail(email)
|
||||
if err != nil {
|
||||
return nil, errors.New("Usuario no encontrado o inactivo")
|
||||
}
|
||||
if !u.Activo {
|
||||
return nil, errors.New("Usuario inactivo")
|
||||
}
|
||||
match, err := app.Http.Hash.Match(password, u.Password)
|
||||
if err != nil || !match {
|
||||
return nil, errors.New("Correo o contraseña incorrectos")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── Proyecto ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Proyecto struct {
|
||||
gorm.Model
|
||||
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
|
||||
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
||||
Slug string `json:"slug" gorm:"column:slug;uniqueIndex;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Color string `json:"color" gorm:"column:color;default:'#8eb02f'"`
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo|pausado|completado
|
||||
Progreso int `json:"progreso" gorm:"column:progreso;default:0"` // 0-100
|
||||
}
|
||||
|
||||
func (Proyecto) TableName() string { return "proyectos" }
|
||||
|
||||
// ─── ProyectoFase (roadmap steps) ─────────────────────────────────────────────
|
||||
|
||||
type ProyectoFase struct {
|
||||
gorm.Model
|
||||
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente|en_progreso|completado
|
||||
Orden int `json:"orden" gorm:"column:orden;default:0"`
|
||||
FechaEstimada *time.Time `json:"fecha_estimada" gorm:"column:fecha_estimada"`
|
||||
FechaCompletado *time.Time `json:"fecha_completado" gorm:"column:fecha_completado"`
|
||||
EntregablesJSON string `json:"entregables_json" gorm:"column:entregables_json;type:text"`
|
||||
Entregables []string `json:"entregables" gorm:"-"`
|
||||
}
|
||||
|
||||
func (ProyectoFase) TableName() string { return "proyecto_fases" }
|
||||
|
||||
func (f *ProyectoFase) ParseEntregables() {
|
||||
if f.EntregablesJSON != "" {
|
||||
_ = json.Unmarshal([]byte(f.EntregablesJSON), &f.Entregables)
|
||||
}
|
||||
if f.Entregables == nil {
|
||||
f.Entregables = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *ProyectoFase) SerializeEntregables() {
|
||||
b, _ := json.Marshal(f.Entregables)
|
||||
f.EntregablesJSON = string(b)
|
||||
}
|
||||
|
||||
// ─── ProyectoAvance (news feed / updates) ─────────────────────────────────────
|
||||
|
||||
type ProyectoAvance struct {
|
||||
gorm.Model
|
||||
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;default:'update'"` // update|milestone|release
|
||||
Visible bool `json:"visible" gorm:"column:visible;default:true"`
|
||||
}
|
||||
|
||||
func (ProyectoAvance) TableName() string { return "proyecto_avances" }
|
||||
|
||||
// ─── ProyectoEntregable (downloadable files) ──────────────────────────────────
|
||||
|
||||
type ProyectoEntregable struct {
|
||||
gorm.Model
|
||||
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
OriginalName string `json:"original_name" gorm:"column:original_name"`
|
||||
Version string `json:"version" gorm:"column:version"`
|
||||
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
|
||||
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
|
||||
Visible bool `json:"visible" gorm:"column:visible;default:true"`
|
||||
}
|
||||
|
||||
func (ProyectoEntregable) TableName() string { return "proyecto_entregables" }
|
||||
|
||||
// ─── Fase template (los 11 pasos de u-site) ───────────────────────────────────
|
||||
|
||||
var FaseTemplateDefault = []struct {
|
||||
Nombre string
|
||||
Descripcion string
|
||||
Entregables []string
|
||||
}{
|
||||
{"Captación y calificación", "Marketing de contenidos, ads y referidos. Primer contacto y diagnóstico rápido. Identificación del tipo de cliente y evaluación de presupuesto.", []string{"Lead calificado", "Brief inicial del problema"}},
|
||||
{"Descubrimiento y diagnóstico", "Reunión profunda sobre procesos y herramientas. Mapeo del flujo de trabajo actual. Identificación de cuellos de botella y priorización.", []string{"Documento de diagnóstico", "Lista de oportunidades"}},
|
||||
{"Definición de solución y alcance", "Diseño de arquitectura modular. Definición de módulos, componentes e integraciones. Priorización por fases (MVP → evolución).", []string{"Propuesta técnica", "Alcance funcional", "Roadmap del proyecto"}},
|
||||
{"Propuesta comercial y cierre", "Definición de modelo de cobro, presentación y ajustes según feedback. Negociación y firma.", []string{"Cotización formal", "Contrato / acuerdo", "Cronograma inicial"}},
|
||||
{"Diseño funcional y técnico", "Wireframes o prototipos, definición de APIs e integraciones, diseño de base de datos.", []string{"Especificación funcional", "Arquitectura definida", "Backlog de desarrollo"}},
|
||||
{"Desarrollo e implementación", "Desarrollo por módulos o iteraciones. Integraciones con herramientas externas. Reuniones de seguimiento.", []string{"Módulos funcionales", "Versiones iterativas"}},
|
||||
{"Pruebas y validación", "Pruebas técnicas y con usuario (UAT). Ajustes, correcciones y validación de flujos completos.", []string{"Sistema validado", "Lista de ajustes finales"}},
|
||||
{"Despliegue y puesta en producción", "Configuración de infraestructura, migración de datos, activación de automatizaciones y monitoreo inicial.", []string{"Sistema en producción", "Documentación básica de uso"}},
|
||||
{"Capacitación y adopción", "Entrenamiento al equipo, guías rápidas o videos, resolución de dudas y ajustes por uso real.", []string{"Usuarios activos", "Procesos adoptados"}},
|
||||
{"Soporte y mejora continua", "Soporte técnico, nuevas automatizaciones o módulos, optimización y evolución del sistema.", []string{"Roadmap evolutivo", "Mejoras continuas"}},
|
||||
{"Cierre formal del proyecto", "Validación final, entrega de documentación, evaluación de resultados (antes vs después) y testimonio.", []string{"Acta de cierre", "Caso de estudio (opcional)", "Oportunidades de upsell"}},
|
||||
}
|
||||
|
||||
// ─── CRUD Proyecto ─────────────────────────────────────────────────────────────
|
||||
|
||||
func GetAllProyectos(limit, offset int, search string) ([]Proyecto, int64, error) {
|
||||
var items []Proyecto
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Proyecto{}).Preload("Cliente")
|
||||
if search != "" {
|
||||
db = db.Where("proyectos.nombre ILIKE ? OR proyectos.descripcion ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("proyectos.created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetProyectosByClienteIDs(clienteIDs []uint) ([]Proyecto, error) {
|
||||
var items []Proyecto
|
||||
err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetProyectoByID(id uint) (*Proyecto, error) {
|
||||
var item Proyecto
|
||||
err := app.Http.Database.DB.Preload("Cliente").First(&item, id).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func GetProyectoBySlug(slug string) (*Proyecto, error) {
|
||||
var item Proyecto
|
||||
err := app.Http.Database.DB.Preload("Cliente").Where("slug = ?", slug).First(&item).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func CreateProyecto(p *Proyecto) error {
|
||||
return app.Http.Database.DB.Create(p).Error
|
||||
}
|
||||
|
||||
func UpdateProyecto(p *Proyecto) error {
|
||||
return app.Http.Database.DB.Model(&Proyecto{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||||
"nombre": p.Nombre,
|
||||
"slug": p.Slug,
|
||||
"descripcion": p.Descripcion,
|
||||
"color": p.Color,
|
||||
"estado": p.Estado,
|
||||
"progreso": p.Progreso,
|
||||
"cliente_id": p.ClienteID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteProyecto(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Proyecto{}, id).Error
|
||||
}
|
||||
|
||||
// ─── CRUD ProyectoFase ─────────────────────────────────────────────────────────
|
||||
|
||||
func GetFasesByProyecto(proyectoID uint) ([]ProyectoFase, error) {
|
||||
var items []ProyectoFase
|
||||
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).Order("orden ASC, id ASC").Find(&items).Error
|
||||
for i := range items {
|
||||
items[i].ParseEntregables()
|
||||
}
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CreateProyectoFase(f *ProyectoFase) error {
|
||||
f.SerializeEntregables()
|
||||
return app.Http.Database.DB.Create(f).Error
|
||||
}
|
||||
|
||||
func UpdateProyectoFase(f *ProyectoFase) error {
|
||||
f.SerializeEntregables()
|
||||
return app.Http.Database.DB.Model(&ProyectoFase{}).Where("id = ?", f.ID).Updates(map[string]interface{}{
|
||||
"nombre": f.Nombre,
|
||||
"descripcion": f.Descripcion,
|
||||
"estado": f.Estado,
|
||||
"orden": f.Orden,
|
||||
"fecha_estimada": f.FechaEstimada,
|
||||
"fecha_completado": f.FechaCompletado,
|
||||
"entregables_json": f.EntregablesJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteProyectoFase(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ProyectoFase{}, id).Error
|
||||
}
|
||||
|
||||
func ApplyFaseTemplate(proyectoID uint) error {
|
||||
if err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).Delete(&ProyectoFase{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i, tpl := range FaseTemplateDefault {
|
||||
ents, _ := json.Marshal(tpl.Entregables)
|
||||
fase := &ProyectoFase{
|
||||
ProyectoID: proyectoID,
|
||||
Nombre: tpl.Nombre,
|
||||
Descripcion: tpl.Descripcion,
|
||||
Orden: i + 1,
|
||||
Estado: "pendiente",
|
||||
EntregablesJSON: string(ents),
|
||||
}
|
||||
if err := app.Http.Database.DB.Create(fase).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── CRUD ProyectoAvance ───────────────────────────────────────────────────────
|
||||
|
||||
func GetAvancesByProyecto(proyectoID uint, soloVisibles bool) ([]ProyectoAvance, error) {
|
||||
var items []ProyectoAvance
|
||||
db := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID)
|
||||
if soloVisibles {
|
||||
db = db.Where("visible = true")
|
||||
}
|
||||
err := db.Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CreateProyectoAvance(a *ProyectoAvance) error {
|
||||
return app.Http.Database.DB.Create(a).Error
|
||||
}
|
||||
|
||||
func UpdateProyectoAvance(a *ProyectoAvance) error {
|
||||
return app.Http.Database.DB.Model(&ProyectoAvance{}).Where("id = ?", a.ID).Updates(map[string]interface{}{
|
||||
"titulo": a.Titulo,
|
||||
"contenido": a.Contenido,
|
||||
"tipo": a.Tipo,
|
||||
"visible": a.Visible,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteProyectoAvance(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ProyectoAvance{}, id).Error
|
||||
}
|
||||
|
||||
// ─── CRUD ProyectoEntregable ───────────────────────────────────────────────────
|
||||
|
||||
func GetEntregablesByProyecto(proyectoID uint, soloVisibles bool) ([]ProyectoEntregable, error) {
|
||||
var items []ProyectoEntregable
|
||||
db := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID)
|
||||
if soloVisibles {
|
||||
db = db.Where("visible = true")
|
||||
}
|
||||
err := db.Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CreateProyectoEntregable(e *ProyectoEntregable) error {
|
||||
return app.Http.Database.DB.Create(e).Error
|
||||
}
|
||||
|
||||
func GetProyectoEntregableByID(id uint) (*ProyectoEntregable, error) {
|
||||
var item ProyectoEntregable
|
||||
err := app.Http.Database.DB.First(&item, id).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func DeleteProyectoEntregable(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ProyectoEntregable{}, id).Error
|
||||
}
|
||||
|
||||
func UpdateEntregableVisibilidad(id uint, visible bool) error {
|
||||
return app.Http.Database.DB.Model(&ProyectoEntregable{}).Where("id = ?", id).Update("visible", visible).Error
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── ProyectoTicket ───────────────────────────────────────────────────────────
|
||||
|
||||
type ProyectoTicket struct {
|
||||
gorm.Model
|
||||
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
|
||||
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
|
||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
||||
}
|
||||
|
||||
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
|
||||
|
||||
// ─── TicketMensaje ────────────────────────────────────────────────────────────
|
||||
|
||||
type TicketMensaje struct {
|
||||
gorm.Model
|
||||
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
}
|
||||
|
||||
func (TicketMensaje) TableName() string { return "ticket_mensajes" }
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) {
|
||||
var items []ProyectoTicket
|
||||
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).
|
||||
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetTicketByID(id uint) (*ProyectoTicket, error) {
|
||||
var item ProyectoTicket
|
||||
err := app.Http.Database.DB.Preload("Mensajes").First(&item, id).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func CreateProyectoTicket(t *ProyectoTicket) error {
|
||||
return app.Http.Database.DB.Create(t).Error
|
||||
}
|
||||
|
||||
func UpdateTicketEstado(id uint, estado string) error {
|
||||
return app.Http.Database.DB.Model(&ProyectoTicket{}).Where("id = ?", id).
|
||||
Update("estado", estado).Error
|
||||
}
|
||||
|
||||
func CreateTicketMensaje(m *TicketMensaje) error {
|
||||
return app.Http.Database.DB.Create(m).Error
|
||||
}
|
||||
|
||||
func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
|
||||
var items []ProyectoTicket
|
||||
err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID).
|
||||
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
func FacturasIndex(c *fiber.Ctx) error {
|
||||
return c.Render("facturas", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func LoadFacturas(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.GetAllFacturas(limit, offset, search)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
clientes, _, _ := models.GetAllClientes(200, 0, "")
|
||||
proyectos, _, _ := models.GetAllProyectos(200, 0, "")
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
"clientes": clientes,
|
||||
"proyectos": proyectos,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateFactura(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
ClienteID uint `json:"cliente_id"`
|
||||
ProyectoID *uint `json:"proyecto_id"`
|
||||
Numero string `json:"numero"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Monto float64 `json:"monto"`
|
||||
Moneda string `json:"moneda"`
|
||||
Estado string `json:"estado"`
|
||||
FechaEmision string `json:"fecha_emision"`
|
||||
FechaVencimiento string `json:"fecha_vencimiento"`
|
||||
Visible bool `json:"visible"`
|
||||
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.ClienteID == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "cliente_id es requerido"})
|
||||
}
|
||||
f := &models.Factura{
|
||||
ClienteID: req.ClienteID,
|
||||
ProyectoID: req.ProyectoID,
|
||||
Numero: req.Numero,
|
||||
Descripcion: req.Descripcion,
|
||||
Monto: req.Monto,
|
||||
Moneda: req.Moneda,
|
||||
Estado: req.Estado,
|
||||
Visible: req.Visible,
|
||||
Notas: req.Notas,
|
||||
FechaEmision: time.Now(),
|
||||
}
|
||||
if req.FechaEmision != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaEmision)
|
||||
if err == nil {
|
||||
f.FechaEmision = t
|
||||
}
|
||||
}
|
||||
if req.FechaVencimiento != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaVencimiento)
|
||||
if err == nil {
|
||||
f.FechaVencimiento = &t
|
||||
}
|
||||
}
|
||||
if f.Moneda == "" {
|
||||
f.Moneda = "COP"
|
||||
}
|
||||
if f.Estado == "" {
|
||||
f.Estado = "pendiente"
|
||||
}
|
||||
if err := models.CreateFactura(f); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(f)
|
||||
}
|
||||
|
||||
func UpdateFactura(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 {
|
||||
ClienteID uint `json:"cliente_id"`
|
||||
ProyectoID *uint `json:"proyecto_id"`
|
||||
Numero string `json:"numero"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Monto float64 `json:"monto"`
|
||||
Moneda string `json:"moneda"`
|
||||
Estado string `json:"estado"`
|
||||
FechaEmision string `json:"fecha_emision"`
|
||||
FechaVencimiento string `json:"fecha_vencimiento"`
|
||||
Visible bool `json:"visible"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
f := &models.Factura{
|
||||
ClienteID: req.ClienteID,
|
||||
ProyectoID: req.ProyectoID,
|
||||
Numero: req.Numero,
|
||||
Descripcion: req.Descripcion,
|
||||
Monto: req.Monto,
|
||||
Moneda: req.Moneda,
|
||||
Estado: req.Estado,
|
||||
Visible: req.Visible,
|
||||
Notas: req.Notas,
|
||||
}
|
||||
f.ID = uint(id)
|
||||
if req.FechaEmision != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaEmision)
|
||||
if err == nil {
|
||||
f.FechaEmision = t
|
||||
}
|
||||
}
|
||||
if req.FechaVencimiento != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaVencimiento)
|
||||
if err == nil {
|
||||
f.FechaVencimiento = &t
|
||||
}
|
||||
}
|
||||
if err := models.UpdateFactura(f); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteFactura(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.DeleteFactura(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func UploadFacturaPDF(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
file, err := c.FormFile("pdf")
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Archivo PDF requerido"})
|
||||
}
|
||||
if file.Size > 20*1024*1024 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Máximo 20MB"})
|
||||
}
|
||||
dir := "uploads/facturas"
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
|
||||
}
|
||||
safeFile := safeFilenameProyecto(file.Filename)
|
||||
savePath := filepath.Join(dir, fmt.Sprintf("%d_%s", id, safeFile))
|
||||
if err := c.SaveFile(file, savePath); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
|
||||
}
|
||||
if err := models.UpdateFacturaArchivo(uint(id), savePath, file.Filename); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "archivo": savePath})
|
||||
}
|
||||
|
||||
func DownloadFacturaPDF(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
f, err := models.GetFacturaByID(uint(id))
|
||||
if err != nil || f.Archivo == "" {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"})
|
||||
}
|
||||
clean := filepath.Clean(f.Archivo)
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.OriginalName))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||
)
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func PortalLoginPage(c *fiber.Ctx) error {
|
||||
if auth.IsPortalLoggedIn(c) {
|
||||
return c.Redirect("/portal/dashboard")
|
||||
}
|
||||
return c.Render("portal/login", fiber.Map{
|
||||
"error": c.Query("error"),
|
||||
}, "layouts/portal_public")
|
||||
}
|
||||
|
||||
func PortalLoginPost(c *fiber.Ctx) error {
|
||||
email := strings.TrimSpace(c.FormValue("email"))
|
||||
password := c.FormValue("password")
|
||||
u, err := models.CheckPortalLogin(email, password)
|
||||
if err != nil {
|
||||
return c.Redirect("/portal/login?error=" + url.QueryEscape(err.Error()))
|
||||
}
|
||||
if err := auth.SetPortalSession(c, u.ID); err != nil {
|
||||
return c.Redirect("/portal/login?error=Error+interno")
|
||||
}
|
||||
return c.Redirect("/portal/dashboard")
|
||||
}
|
||||
|
||||
func PortalLogout(c *fiber.Ctx) error {
|
||||
_ = auth.DestroyPortalSession(c)
|
||||
return c.Redirect("/portal/login")
|
||||
}
|
||||
|
||||
// ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
func PortalDashboard(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Redirect("/portal/login")
|
||||
}
|
||||
|
||||
// Recargar con accesos
|
||||
fullUser, err := models.GetPortalUserByID(u.ID)
|
||||
if err != nil {
|
||||
return c.Redirect("/portal/login")
|
||||
}
|
||||
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
|
||||
proyectos, _ := models.GetProyectosByClienteIDs(clienteIDs)
|
||||
|
||||
// Si partner: agrupar proyectos por cliente
|
||||
type ClienteProyectos struct {
|
||||
Cliente models.Cliente
|
||||
Proyectos []models.Proyecto
|
||||
}
|
||||
var grupos []ClienteProyectos
|
||||
clienteMap := map[uint]*ClienteProyectos{}
|
||||
for _, p := range proyectos {
|
||||
if _, ok := clienteMap[p.ClienteID]; !ok {
|
||||
clienteMap[p.ClienteID] = &ClienteProyectos{Cliente: p.Cliente}
|
||||
}
|
||||
clienteMap[p.ClienteID].Proyectos = append(clienteMap[p.ClienteID].Proyectos, p)
|
||||
}
|
||||
for _, g := range clienteMap {
|
||||
grupos = append(grupos, *g)
|
||||
}
|
||||
|
||||
return c.Render("portal/dashboard", fiber.Map{
|
||||
"portalUser": fullUser,
|
||||
"proyectos": proyectos,
|
||||
"grupos": grupos,
|
||||
"isPartner": fullUser.Rol == "partner",
|
||||
}, "layouts/portal")
|
||||
}
|
||||
|
||||
// ─── Detalle de proyecto ──────────────────────────────────────────────────────
|
||||
|
||||
func PortalProyecto(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Redirect("/portal/login")
|
||||
}
|
||||
|
||||
slug := c.Params("slug")
|
||||
proy, err := models.GetProyectoBySlug(slug)
|
||||
if err != nil {
|
||||
return c.Status(404).Render("portal/404", fiber.Map{}, "layouts/portal")
|
||||
}
|
||||
|
||||
// Verificar acceso
|
||||
fullUser, _ := models.GetPortalUserByID(u.ID)
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
|
||||
hasAccess := false
|
||||
for _, cid := range clienteIDs {
|
||||
if cid == proy.ClienteID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess {
|
||||
return c.Status(403).Redirect("/portal/dashboard")
|
||||
}
|
||||
|
||||
fases, _ := models.GetFasesByProyecto(proy.ID)
|
||||
avances, _ := models.GetAvancesByProyecto(proy.ID, true)
|
||||
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
|
||||
tickets, _ := models.GetTicketsByPortalUser(u.ID)
|
||||
|
||||
return c.Render("portal/proyecto", fiber.Map{
|
||||
"portalUser": fullUser,
|
||||
"proyecto": proy,
|
||||
"fases": fases,
|
||||
"avances": avances,
|
||||
"entregables": entregables,
|
||||
"tickets": tickets,
|
||||
}, "layouts/portal")
|
||||
}
|
||||
|
||||
// ─── API del portal ───────────────────────────────────────────────────────────
|
||||
|
||||
// PortalGetProyectos devuelve los proyectos del usuario logueado (JSON para Alpine).
|
||||
func PortalGetProyectos(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
fullUser, _ := models.GetPortalUserByID(u.ID)
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
|
||||
proyectos, _ := models.GetProyectosByClienteIDs(clienteIDs)
|
||||
return c.JSON(proyectos)
|
||||
}
|
||||
|
||||
// PortalGetProyectoData devuelve todo el contenido de un proyecto (JSON).
|
||||
func PortalGetProyectoData(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
slug := c.Params("slug")
|
||||
proy, err := models.GetProyectoBySlug(slug)
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
// Verificar acceso
|
||||
fullUser, _ := models.GetPortalUserByID(u.ID)
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
|
||||
hasAccess := false
|
||||
for _, cid := range clienteIDs {
|
||||
if cid == proy.ClienteID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "sin acceso"})
|
||||
}
|
||||
|
||||
fases, _ := models.GetFasesByProyecto(proy.ID)
|
||||
avances, _ := models.GetAvancesByProyecto(proy.ID, true)
|
||||
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
|
||||
tickets, _ := models.GetTicketsByPortalUser(u.ID)
|
||||
facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"proyecto": proy,
|
||||
"fases": fases,
|
||||
"avances": avances,
|
||||
"entregables": entregables,
|
||||
"tickets": tickets,
|
||||
"facturas": facturas,
|
||||
})
|
||||
}
|
||||
|
||||
// PortalCrearTicket crea un nuevo ticket de soporte.
|
||||
func PortalCrearTicket(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
type Req struct {
|
||||
ProyectoSlug string `json:"proyecto_slug"`
|
||||
Titulo string `json:"titulo"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Prioridad string `json:"prioridad"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if strings.TrimSpace(req.Titulo) == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "El título es requerido"})
|
||||
}
|
||||
|
||||
proy, err := models.GetProyectoBySlug(req.ProyectoSlug)
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"})
|
||||
}
|
||||
|
||||
t := &models.ProyectoTicket{
|
||||
ProyectoID: proy.ID,
|
||||
PortalUserID: u.ID,
|
||||
AutorNombre: u.Nombre,
|
||||
Titulo: req.Titulo,
|
||||
Descripcion: req.Descripcion,
|
||||
Prioridad: req.Prioridad,
|
||||
Estado: "abierto",
|
||||
}
|
||||
if t.Prioridad == "" {
|
||||
t.Prioridad = "media"
|
||||
}
|
||||
if err := models.CreateProyectoTicket(t); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(t)
|
||||
}
|
||||
|
||||
// PortalResponderTicket agrega un mensaje a un ticket.
|
||||
func PortalResponderTicket(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
ticketID, _ := c.ParamsInt("id")
|
||||
type Req struct{ Contenido string `json:"contenido"` }
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if strings.TrimSpace(req.Contenido) == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"})
|
||||
}
|
||||
ticket, err := models.GetTicketByID(uint(ticketID))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
|
||||
}
|
||||
if ticket.PortalUserID != u.ID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
|
||||
}
|
||||
msg := &models.TicketMensaje{
|
||||
TicketID: uint(ticketID),
|
||||
Contenido: req.Contenido,
|
||||
EsAdmin: false,
|
||||
AutorNombre: u.Nombre,
|
||||
}
|
||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(msg)
|
||||
}
|
||||
|
||||
// PortalDownloadEntregable descarga un entregable del portal.
|
||||
func PortalDownloadEntregable(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
entID, _ := c.ParamsInt("id")
|
||||
item, err := models.GetProyectoEntregableByID(uint(entID))
|
||||
if err != nil || !item.Visible {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
// Verificar que el proyecto pertenece al cliente del portal user
|
||||
proy, err := models.GetProyectoByID(item.ProyectoID)
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"})
|
||||
}
|
||||
fullUser, _ := models.GetPortalUserByID(u.ID)
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
|
||||
hasAccess := false
|
||||
for _, cid := range clienteIDs {
|
||||
if cid == proy.ClienteID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
|
||||
}
|
||||
clean := item.Archivo
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
|
||||
// PortalDownloadFactura descarga el PDF de una factura del portal.
|
||||
func PortalDownloadFactura(c *fiber.Ctx) error {
|
||||
u := middlewares.PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
factID, _ := c.ParamsInt("id")
|
||||
f, err := models.GetFacturaByID(uint(factID))
|
||||
if err != nil || !f.Visible || f.Archivo == "" {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
// Verificar acceso
|
||||
fullUser, _ := models.GetPortalUserByID(u.ID)
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
|
||||
hasAccess := false
|
||||
for _, cid := range clienteIDs {
|
||||
if cid == f.ClienteID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
|
||||
}
|
||||
clean := f.Archivo
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.OriginalName))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
func PortalUsuariosIndex(c *fiber.Ctx) error {
|
||||
return c.Render("portal_usuarios", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func LoadPortalUsuarios(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
items, total, err := models.GetAllPortalUsers(limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
clientes, _, _ := models.GetAllClientes(200, 0, "")
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
"clientes": clientes,
|
||||
})
|
||||
}
|
||||
|
||||
func CreatePortalUsuario(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
Nombre string `json:"nombre"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
ClienteID *uint `json:"cliente_id"`
|
||||
Rol string `json:"rol"`
|
||||
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 strings.TrimSpace(req.Nombre) == "" || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "nombre, email y password son requeridos"})
|
||||
}
|
||||
hashed, err := app.Http.Hash.Create(req.Password)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Error al hashear contraseña"})
|
||||
}
|
||||
if req.Rol == "" {
|
||||
req.Rol = "cliente"
|
||||
}
|
||||
u := &models.PortalUser{
|
||||
Nombre: req.Nombre,
|
||||
Email: strings.ToLower(strings.TrimSpace(req.Email)),
|
||||
Password: hashed,
|
||||
ClienteID: req.ClienteID,
|
||||
Rol: req.Rol,
|
||||
Activo: true,
|
||||
Notas: req.Notas,
|
||||
}
|
||||
if err := models.CreatePortalUser(u); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
u.Password = ""
|
||||
return c.Status(201).JSON(u)
|
||||
}
|
||||
|
||||
func UpdatePortalUsuario(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 {
|
||||
Nombre string `json:"nombre"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
ClienteID *uint `json:"cliente_id"`
|
||||
Rol string `json:"rol"`
|
||||
Activo bool `json:"activo"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
u := &models.PortalUser{
|
||||
Nombre: req.Nombre,
|
||||
Email: strings.ToLower(strings.TrimSpace(req.Email)),
|
||||
ClienteID: req.ClienteID,
|
||||
Rol: req.Rol,
|
||||
Activo: req.Activo,
|
||||
Notas: req.Notas,
|
||||
}
|
||||
u.ID = uint(id)
|
||||
if err := models.UpdatePortalUser(u); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// Actualizar password solo si se envió
|
||||
if strings.TrimSpace(req.Password) != "" {
|
||||
hashed, err := app.Http.Hash.Create(req.Password)
|
||||
if err == nil {
|
||||
_ = models.UpdatePortalUserPassword(uint(id), hashed)
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeletePortalUsuario(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.DeletePortalUser(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func AddPortalAcceso(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
type Req struct {
|
||||
ClienteID uint `json:"cliente_id"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if err := models.AddPortalAcceso(uint(id), req.ClienteID); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func RemovePortalAcceso(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
clienteID, _ := strconv.ParseUint(c.Params("clienteID"), 10, 32)
|
||||
if err := models.RemovePortalAcceso(uint(id), uint(clienteID)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// ─── Admin pages ──────────────────────────────────────────────────────────────
|
||||
|
||||
func ProyectosIndex(c *fiber.Ctx) error {
|
||||
return c.Render("proyectos", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func ProyectoDetalleIndex(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Redirect("/app/proyectos")
|
||||
}
|
||||
proy, err := models.GetProyectoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Redirect("/app/proyectos")
|
||||
}
|
||||
return c.Render("proyecto_detalle", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
"proyecto": proy,
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
// ─── CRUD Proyecto ─────────────────────────────────────────────────────────────
|
||||
|
||||
func LoadProyectos(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.GetAllProyectos(limit, offset, search)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// Obtener clientes para el formulario
|
||||
clientes, _, _ := models.GetAllClientes(200, 0, "")
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||
"page": page,
|
||||
"clientes": clientes,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateProyecto(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
ClienteID uint `json:"cliente_id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Slug string `json:"slug"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Color string `json:"color"`
|
||||
Estado string `json:"estado"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if req.ClienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"})
|
||||
}
|
||||
if req.Slug == "" {
|
||||
req.Slug = slugify(req.Nombre)
|
||||
}
|
||||
if req.Color == "" {
|
||||
req.Color = "#8eb02f"
|
||||
}
|
||||
if req.Estado == "" {
|
||||
req.Estado = "activo"
|
||||
}
|
||||
p := &models.Proyecto{
|
||||
ClienteID: req.ClienteID,
|
||||
Nombre: req.Nombre,
|
||||
Slug: req.Slug,
|
||||
Descripcion: req.Descripcion,
|
||||
Color: req.Color,
|
||||
Estado: req.Estado,
|
||||
}
|
||||
if err := models.CreateProyecto(p); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(p)
|
||||
}
|
||||
|
||||
func UpdateProyecto(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 {
|
||||
ClienteID uint `json:"cliente_id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Slug string `json:"slug"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Color string `json:"color"`
|
||||
Estado string `json:"estado"`
|
||||
Progreso int `json:"progreso"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
p := &models.Proyecto{
|
||||
ClienteID: req.ClienteID,
|
||||
Nombre: req.Nombre,
|
||||
Slug: req.Slug,
|
||||
Descripcion: req.Descripcion,
|
||||
Color: req.Color,
|
||||
Estado: req.Estado,
|
||||
Progreso: req.Progreso,
|
||||
}
|
||||
p.ID = uint(id)
|
||||
if err := models.UpdateProyecto(p); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteProyecto(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.DeleteProyecto(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── Fases ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetFases(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
fases, err := models.GetFasesByProyecto(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fases)
|
||||
}
|
||||
|
||||
func CreateFase(c *fiber.Ctx) error {
|
||||
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
type Req struct {
|
||||
Nombre string `json:"nombre"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Estado string `json:"estado"`
|
||||
Orden int `json:"orden"`
|
||||
FechaEstimada string `json:"fecha_estimada"`
|
||||
Entregables []string `json:"entregables"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
f := &models.ProyectoFase{
|
||||
ProyectoID: uint(proyID),
|
||||
Nombre: req.Nombre,
|
||||
Descripcion: req.Descripcion,
|
||||
Estado: req.Estado,
|
||||
Orden: req.Orden,
|
||||
Entregables: req.Entregables,
|
||||
}
|
||||
if req.FechaEstimada != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaEstimada)
|
||||
if err == nil {
|
||||
f.FechaEstimada = &t
|
||||
}
|
||||
}
|
||||
if err := models.CreateProyectoFase(f); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
f.ParseEntregables()
|
||||
return c.Status(201).JSON(f)
|
||||
}
|
||||
|
||||
func UpdateFase(c *fiber.Ctx) error {
|
||||
faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32)
|
||||
type Req struct {
|
||||
Nombre string `json:"nombre"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Estado string `json:"estado"`
|
||||
Orden int `json:"orden"`
|
||||
FechaEstimada string `json:"fecha_estimada"`
|
||||
FechaCompletado string `json:"fecha_completado"`
|
||||
Entregables []string `json:"entregables"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
f := &models.ProyectoFase{
|
||||
Nombre: req.Nombre,
|
||||
Descripcion: req.Descripcion,
|
||||
Estado: req.Estado,
|
||||
Orden: req.Orden,
|
||||
Entregables: req.Entregables,
|
||||
}
|
||||
f.ID = uint(faseID)
|
||||
if req.FechaEstimada != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaEstimada)
|
||||
if err == nil {
|
||||
f.FechaEstimada = &t
|
||||
}
|
||||
}
|
||||
if req.FechaCompletado != "" {
|
||||
t, err := time.Parse("2006-01-02", req.FechaCompletado)
|
||||
if err == nil {
|
||||
f.FechaCompletado = &t
|
||||
}
|
||||
}
|
||||
// Auto-set fecha_completado when estado = completado
|
||||
if req.Estado == "completado" && f.FechaCompletado == nil {
|
||||
now := time.Now()
|
||||
f.FechaCompletado = &now
|
||||
}
|
||||
if err := models.UpdateProyectoFase(f); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteFase(c *fiber.Ctx) error {
|
||||
faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32)
|
||||
if err := models.DeleteProyectoFase(uint(faseID)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func ApplyTemplate(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err := models.ApplyFaseTemplate(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
fases, _ := models.GetFasesByProyecto(uint(id))
|
||||
return c.JSON(fases)
|
||||
}
|
||||
|
||||
// ─── Avances ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetAvances(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
avances, err := models.GetAvancesByProyecto(uint(id), false)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(avances)
|
||||
}
|
||||
|
||||
func CreateAvance(c *fiber.Ctx) error {
|
||||
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
var a models.ProyectoAvance
|
||||
if err := c.BodyParser(&a); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
a.ProyectoID = uint(proyID)
|
||||
if a.Tipo == "" {
|
||||
a.Tipo = "update"
|
||||
}
|
||||
a.Visible = true
|
||||
if err := models.CreateProyectoAvance(&a); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(a)
|
||||
}
|
||||
|
||||
func UpdateAvance(c *fiber.Ctx) error {
|
||||
avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32)
|
||||
var a models.ProyectoAvance
|
||||
if err := c.BodyParser(&a); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
a.ID = uint(avID)
|
||||
if err := models.UpdateProyectoAvance(&a); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteAvance(c *fiber.Ctx) error {
|
||||
avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32)
|
||||
if err := models.DeleteProyectoAvance(uint(avID)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── Entregables ──────────────────────────────────────────────────────────────
|
||||
|
||||
func GetEntregables(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
items, err := models.GetEntregablesByProyecto(uint(id), false)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
func UploadEntregable(c *fiber.Ctx) error {
|
||||
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"})
|
||||
}
|
||||
nombre := c.FormValue("nombre")
|
||||
descripcion := c.FormValue("descripcion")
|
||||
version := c.FormValue("version", "1.0")
|
||||
|
||||
file, err := c.FormFile("archivo")
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
|
||||
}
|
||||
if file.Size > 50*1024*1024 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"})
|
||||
}
|
||||
|
||||
dir := fmt.Sprintf("uploads/proyectos/%d", proyID)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
|
||||
}
|
||||
|
||||
safeFile := safeFilenameProyecto(file.Filename)
|
||||
savePath := filepath.Join(dir, safeFile)
|
||||
if err := c.SaveFile(file, savePath); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
|
||||
}
|
||||
|
||||
e := &models.ProyectoEntregable{
|
||||
ProyectoID: uint(proyID),
|
||||
Nombre: nombre,
|
||||
Descripcion: descripcion,
|
||||
Archivo: savePath,
|
||||
OriginalName: file.Filename,
|
||||
Version: version,
|
||||
TipoMime: mimeFromHeader(file),
|
||||
Tamanio: file.Size,
|
||||
Visible: true,
|
||||
}
|
||||
if err := models.CreateProyectoEntregable(e); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(e)
|
||||
}
|
||||
|
||||
func DeleteEntregable(c *fiber.Ctx) error {
|
||||
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
||||
item, err := models.GetProyectoEntregableByID(uint(entID))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
_ = os.Remove(item.Archivo)
|
||||
if err := models.DeleteProyectoEntregable(uint(entID)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DownloadEntregable(c *fiber.Ctx) error {
|
||||
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
||||
item, err := models.GetProyectoEntregableByID(uint(entID))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
clean := filepath.Clean(item.Archivo)
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
|
||||
func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
|
||||
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
||||
type Req struct{ Visible bool `json:"visible"` }
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if err := models.UpdateEntregableVisibilidad(uint(entID), req.Visible); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── Tickets (admin) ──────────────────────────────────────────────────────────
|
||||
|
||||
func GetTickets(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
items, err := models.GetTicketsByProyecto(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ Estado string `json:"estado"` }
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if err := models.UpdateTicketEstado(uint(ticketID), req.Estado); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func AdminResponderTicket(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ Contenido string `json:"contenido"` }
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if strings.TrimSpace(req.Contenido) == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"})
|
||||
}
|
||||
// Obtener nombre del admin
|
||||
adminNombre := "Soporte"
|
||||
if u := c.Locals("user"); u != nil {
|
||||
if m, ok := u.(map[string]interface{}); ok {
|
||||
if n, ok := m["Name"].(string); ok && n != "" {
|
||||
adminNombre = n
|
||||
}
|
||||
}
|
||||
}
|
||||
msg := &models.TicketMensaje{
|
||||
TicketID: uint(ticketID),
|
||||
Contenido: req.Contenido,
|
||||
EsAdmin: true,
|
||||
AutorNombre: adminNombre,
|
||||
}
|
||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// Cambiar estado a en_progreso si estaba abierto
|
||||
_ = models.UpdateTicketEstado(uint(ticketID), "en_progreso")
|
||||
return c.Status(201).JSON(msg)
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func slugify(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
replacer := strings.NewReplacer(
|
||||
" ", "-", "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u",
|
||||
"ñ", "n", "ü", "u",
|
||||
)
|
||||
s = replacer.Replace(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func safeFilenameProyecto(name string) string {
|
||||
base := filepath.Base(name)
|
||||
ext := filepath.Ext(base)
|
||||
stem := strings.TrimSuffix(base, ext)
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, stem)
|
||||
ts := strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
return safe + "_" + ts + ext
|
||||
}
|
||||
|
||||
func mimeFromHeader(fh *multipart.FileHeader) string {
|
||||
ct := fh.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return ct
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// PortalAuth protege las rutas del portal de clientes.
|
||||
// Si no hay sesión activa, redirige a /portal/login.
|
||||
func PortalAuth() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
user, err := auth.PortalUser(c)
|
||||
if err != nil || user == nil {
|
||||
return c.Redirect("/portal/login")
|
||||
}
|
||||
c.Locals("portalUser", user)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// LoadPortalUserMiddleware carga el portal user en locals (sin redirigir).
|
||||
func LoadPortalUserMiddleware(c *fiber.Ctx) error {
|
||||
user, err := auth.PortalUser(c)
|
||||
if err == nil && user != nil {
|
||||
c.Locals("portalUser", user)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// PortalUserFromLocals extrae el portal user del contexto (helper para controladores).
|
||||
func PortalUserFromLocals(c *fiber.Ctx) *models.PortalUser {
|
||||
u, _ := c.Locals("portalUser").(*models.PortalUser)
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||
)
|
||||
|
||||
func PortalRoutes(app fiber.Router) {
|
||||
// ─── Rutas públicas ────────────────────────────────────────────────────────
|
||||
app.Get("/portal/login", controllers.PortalLoginPage)
|
||||
app.Post("/portal/login", controllers.PortalLoginPost)
|
||||
app.Get("/portal/logout", controllers.PortalLogout)
|
||||
|
||||
// ─── Rutas protegidas ──────────────────────────────────────────────────────
|
||||
portal := app.Group("/portal").Use(middlewares.PortalAuth())
|
||||
|
||||
portal.Get("/dashboard", controllers.PortalDashboard)
|
||||
portal.Get("/proyecto/:slug", controllers.PortalProyecto)
|
||||
|
||||
// API JSON para Alpine.js
|
||||
portal.Get("/api/proyectos", controllers.PortalGetProyectos)
|
||||
portal.Get("/api/proyecto/:slug", controllers.PortalGetProyectoData)
|
||||
|
||||
// Tickets
|
||||
portal.Post("/tickets", controllers.PortalCrearTicket)
|
||||
portal.Post("/tickets/:id/mensaje", controllers.PortalResponderTicket)
|
||||
|
||||
// Descargas
|
||||
portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable)
|
||||
portal.Get("/facturas/:id/download", controllers.PortalDownloadFactura)
|
||||
}
|
||||
@@ -222,6 +222,47 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Post("/telegram/send", controllers.SendTelegramNotification)
|
||||
protected.Get("/telegram/logs", controllers.GetTelegramLogs)
|
||||
|
||||
// ─── Portal de Clientes ────────────────────────────────────────────────────
|
||||
protected.Get("/proyectos", middlewares.MenuMiddleware, controllers.ProyectosIndex)
|
||||
protected.Get("/loadproyectos", controllers.LoadProyectos)
|
||||
protected.Post("/proyectos", controllers.CreateProyecto)
|
||||
protected.Put("/proyectos/:id", controllers.UpdateProyecto)
|
||||
protected.Delete("/proyectos/:id", controllers.DeleteProyecto)
|
||||
protected.Get("/proyectos/:id/detalle", middlewares.MenuMiddleware, controllers.ProyectoDetalleIndex)
|
||||
protected.Get("/proyectos/:id/fases", controllers.GetFases)
|
||||
protected.Post("/proyectos/:id/fases", controllers.CreateFase)
|
||||
protected.Put("/proyectos/:id/fases/:faseID", controllers.UpdateFase)
|
||||
protected.Delete("/proyectos/:id/fases/:faseID", controllers.DeleteFase)
|
||||
protected.Post("/proyectos/:id/fases/template", controllers.ApplyTemplate)
|
||||
protected.Get("/proyectos/:id/avances", controllers.GetAvances)
|
||||
protected.Post("/proyectos/:id/avances", controllers.CreateAvance)
|
||||
protected.Put("/proyectos/:id/avances/:avID", controllers.UpdateAvance)
|
||||
protected.Delete("/proyectos/:id/avances/:avID", controllers.DeleteAvance)
|
||||
protected.Get("/proyectos/:id/entregables", controllers.GetEntregables)
|
||||
protected.Post("/proyectos/:id/entregables", controllers.UploadEntregable)
|
||||
protected.Delete("/proyectos/:id/entregables/:entID", controllers.DeleteEntregable)
|
||||
protected.Get("/proyectos/:id/entregables/:entID/download", controllers.DownloadEntregable)
|
||||
protected.Put("/proyectos/:id/entregables/:entID/visibilidad", controllers.UpdateEntregableVisibilidad)
|
||||
protected.Get("/proyectos/:id/tickets", controllers.GetTickets)
|
||||
protected.Put("/proyectos/:id/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
||||
protected.Post("/proyectos/:id/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
||||
|
||||
protected.Get("/portal-usuarios", middlewares.MenuMiddleware, controllers.PortalUsuariosIndex)
|
||||
protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios)
|
||||
protected.Post("/portal-usuarios", controllers.CreatePortalUsuario)
|
||||
protected.Put("/portal-usuarios/:id", controllers.UpdatePortalUsuario)
|
||||
protected.Delete("/portal-usuarios/:id", controllers.DeletePortalUsuario)
|
||||
protected.Post("/portal-usuarios/:id/acceso", controllers.AddPortalAcceso)
|
||||
protected.Delete("/portal-usuarios/:id/acceso/:clienteID", controllers.RemovePortalAcceso)
|
||||
|
||||
protected.Get("/facturas", middlewares.MenuMiddleware, controllers.FacturasIndex)
|
||||
protected.Get("/loadfacturas", controllers.LoadFacturas)
|
||||
protected.Post("/facturas", controllers.CreateFactura)
|
||||
protected.Put("/facturas/:id", controllers.UpdateFactura)
|
||||
protected.Delete("/facturas/:id", controllers.DeleteFactura)
|
||||
protected.Post("/facturas/:id/upload-pdf", controllers.UploadFacturaPDF)
|
||||
protected.Get("/facturas/:id/download", controllers.DownloadFacturaPDF)
|
||||
|
||||
// ─── Planes dLocal (gestión desde panel protegido) ────────────────────────
|
||||
protected.Get("/dlocal/planes", apiControllers.SeePlanes)
|
||||
protected.Post("/dlocal/planes", apiControllers.CreatePlan)
|
||||
|
||||
+1
-1
@@ -15,5 +15,5 @@ func WebRoutes(web fiber.Router) {
|
||||
LandingRoutes(web)
|
||||
WebAuthRoutes(web)
|
||||
UserRoutes(web)
|
||||
|
||||
PortalRoutes(web)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user