feat(portal): descarga del cronograma del proyecto en Excel

El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a
alguien que no entra al portal.

El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de
sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado
no servía: en Excel en español el separador y los acentos se rompen, y
esto es un archivo que el cliente le entrega a un tercero.

Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin
tabla sharedStrings, así ningún índice puede apuntar a la cadena
equivocada), encabezado en negrita y congelado, anchos de columna según
el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa
de 31 caracteres o trae : \ / ? * [ ]).

El endpoint repite el chequeo de acceso de PortalProyecto: sin eso,
cualquiera con sesión de portal se bajaba el cronograma de otro cliente
adivinando el slug.

Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK,
XML bien formado en todas, acentos y CJK intactos, negrita y panel
congelado donde corresponde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-14 22:12:45 -05:00
co-authored by Claude Sonnet 5
parent 5182c45d11
commit 62c644b815
5 changed files with 358 additions and 0 deletions
+73
View File
@@ -1,6 +1,7 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"log"
@@ -8,7 +9,9 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -897,3 +900,73 @@ func searchTokenInUpdates(botToken, token string) (int64, bool) {
}
return 0, false
}
// PortalDescargarCronograma entrega el cronograma del proyecto como .xlsx,
// para que el cliente pueda mandárselo a alguien que no entra al portal.
// Ruta: GET /portal/proyecto/:slug/cronograma.xlsx
func PortalDescargarCronograma(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Redirect("/portal/login")
}
proy, err := models.GetProyectoBySlug(c.Params("slug"))
if err != nil {
return c.Status(404).SendString("proyecto no encontrado")
}
// Mismo chequeo de acceso que PortalProyecto: sin esto, cualquiera con
// sesión de portal podría bajarse el cronograma de otro cliente
// adivinando el slug.
fullUser, _ := models.GetPortalUserByID(u.ID)
permitido := false
for _, cid := range models.GetClienteIDsForPortalUser(fullUser) {
if cid == proy.ClienteID {
permitido = true
break
}
}
if !permitido {
return c.Status(403).SendString("sin acceso a este proyecto")
}
fases, _ := models.GetFasesByProyecto(proy.ID)
estados := map[string]string{
"pendiente": "Pendiente",
"en_progreso": "En progreso",
"completado": "Completado",
}
fecha := func(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("02/01/2006")
}
filas := [][]string{{"#", "Fase", "Descripción", "Estado", "Fecha estimada", "Fecha completado", "Entregables"}}
for i, f := range fases {
f.ParseEntregables()
estado := estados[f.Estado]
if estado == "" {
estado = f.Estado
}
filas = append(filas, []string{
strconv.Itoa(i + 1),
f.Nombre,
f.Descripcion,
estado,
fecha(f.FechaEstimada),
fecha(f.FechaCompletado),
strings.Join(f.Entregables, ", "),
})
}
nombre := fmt.Sprintf("cronograma-%s-%s.xlsx", proy.Slug, time.Now().Format("2006-01-02"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", `attachment; filename="`+nombre+`"`)
var buf bytes.Buffer
if err := services.EscribirXLSX(&buf, "Cronograma", filas); err != nil {
return c.Status(500).SendString("no se pudo generar el archivo")
}
return c.Send(buf.Bytes())
}