This commit is contained in:
Lizandro Guarnizo
2026-05-14 22:52:10 -05:00
parent 61b3eca31d
commit 2377371b2c
14 changed files with 1938 additions and 1 deletions
+203
View File
@@ -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)
}
+326
View File
@@ -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})
}
+500
View File
@@ -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
}
+35
View File
@@ -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
}
+32
View File
@@ -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)
}
+41
View File
@@ -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
View File
@@ -15,5 +15,5 @@ func WebRoutes(web fiber.Router) {
LandingRoutes(web)
WebAuthRoutes(web)
UserRoutes(web)
PortalRoutes(web)
}