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
+182
View File
@@ -0,0 +1,182 @@
package services
import (
"archive/zip"
"encoding/xml"
"fmt"
"io"
"strings"
)
// Un .xlsx es un zip con unos pocos XML dentro. Generarlo con archive/zip
// evita sumar una dependencia de Excel para una sola pantalla de descarga.
//
// Se usan inline strings (t="inlineStr") en vez de la tabla sharedStrings:
// ocupa un poco más, pero ahorra una parte entera del archivo y elimina la
// posibilidad de que un índice quede apuntando a la cadena equivocada.
//
// ponytail: solo texto, sin tipos ni formato de celda. Las fechas se
// escriben ya formateadas como texto — si alguna vez hace falta ordenar o
// hacer cuentas dentro de Excel, ahí sí conviene excelize.
// EscribirXLSX vuelca las filas en una hoja. La primera fila se toma como
// encabezado (queda en negrita y congelada).
func EscribirXLSX(w io.Writer, nombreHoja string, filas [][]string) error {
if nombreHoja == "" {
nombreHoja = "Hoja1"
}
// Excel rechaza el archivo si el nombre de hoja pasa de 31 caracteres o
// trae : \ / ? * [ ]
nombreHoja = limpiarNombreHoja(nombreHoja)
z := zip.NewWriter(w)
partes := []struct{ nombre, cuerpo string }{
{"[Content_Types].xml", contentTypesXML},
{"_rels/.rels", relsXML},
{"xl/workbook.xml", fmt.Sprintf(workbookXML, escaparXML(nombreHoja))},
{"xl/_rels/workbook.xml.rels", workbookRelsXML},
{"xl/styles.xml", stylesXML},
{"xl/worksheets/sheet1.xml", hojaXML(filas)},
}
for _, p := range partes {
f, err := z.Create(p.nombre)
if err != nil {
return err
}
if _, err := io.WriteString(f, p.cuerpo); err != nil {
return err
}
}
return z.Close()
}
func limpiarNombreHoja(n string) string {
n = strings.Map(func(r rune) rune {
if strings.ContainsRune(`:\/?*[]`, r) {
return '-'
}
return r
}, n)
if len([]rune(n)) > 31 {
n = string([]rune(n)[:31])
}
return n
}
// columna convierte 0 → A, 25 → Z, 26 → AA.
func columna(i int) string {
nombre := ""
for i >= 0 {
nombre = string(rune('A'+i%26)) + nombre
i = i/26 - 1
}
return nombre
}
func escaparXML(s string) string {
var b strings.Builder
// xml.EscapeText también neutraliza los caracteres de control que Excel
// rechaza, que es justo lo que puede venir en un texto pegado por el
// usuario.
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
func hojaXML(filas [][]string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`)
// Congelar el encabezado: en un cronograma largo, perderlo al scrollear
// es lo primero que molesta.
b.WriteString(`<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>`)
b.WriteString(`<sheetFormatPr defaultRowHeight="15"/>`)
b.WriteString(anchoColumnas(filas))
b.WriteString(`<sheetData>`)
for i, fila := range filas {
estilo := ""
if i == 0 {
estilo = ` s="1"` // encabezado en negrita
}
fmt.Fprintf(&b, `<row r="%d">`, i+1)
for j, celda := range fila {
fmt.Fprintf(&b,
`<c r="%s%d" t="inlineStr"%s><is><t xml:space="preserve">%s</t></is></c>`,
columna(j), i+1, estilo, escaparXML(celda))
}
b.WriteString(`</row>`)
}
b.WriteString(`</sheetData></worksheet>`)
return b.String()
}
// anchoColumnas estima el ancho por el contenido más largo. Sin esto todo
// sale en el ancho por defecto y el cronograma se lee como una columna de
// almohadillas.
func anchoColumnas(filas [][]string) string {
if len(filas) == 0 {
return ""
}
maxCols := 0
for _, f := range filas {
if len(f) > maxCols {
maxCols = len(f)
}
}
anchos := make([]int, maxCols)
for _, f := range filas {
for j, celda := range f {
if n := len([]rune(celda)); n > anchos[j] {
anchos[j] = n
}
}
}
var b strings.Builder
b.WriteString(`<cols>`)
for j, a := range anchos {
if a < 10 {
a = 10
}
if a > 60 { // más allá de esto conviene que Excel corte, no scrollear al infinito
a = 60
}
fmt.Fprintf(&b, `<col min="%d" max="%d" width="%d" customWidth="1"/>`, j+1, j+1, a+2)
}
b.WriteString(`</cols>`)
return b.String()
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>`
const relsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="%s" sheetId="1" r:id="rId1"/></sheets>
</workbook>`
const workbookRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`
// Dos estilos: 0 normal, 1 negrita (el encabezado).
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`
+91
View File
@@ -0,0 +1,91 @@
package services
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strings"
"testing"
)
// El .xlsx se arma a mano, así que hay que confirmar que es un zip válido con
// todas las partes y XML bien formado — Excel no perdona ninguna de las dos.
func TestEscribirXLSX(t *testing.T) {
filas := [][]string{
{"#", "Fase", "Descripción", "Estado"},
{"1", "Diseño & maquetación", "Wireframes <UX>", "Completado"},
{"2", "Integración API", "Conectar \"pasarela\" de pago", "En progreso"},
}
var buf bytes.Buffer
if err := EscribirXLSX(&buf, "Cronograma", filas); err != nil {
t.Fatalf("EscribirXLSX: %v", err)
}
z, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("no es un zip válido: %v", err)
}
requeridas := []string{
"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml",
"xl/_rels/workbook.xml.rels", "xl/styles.xml", "xl/worksheets/sheet1.xml",
}
presentes := map[string]string{}
for _, f := range z.File {
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
presentes[f.Name] = string(b)
}
for _, r := range requeridas {
if _, ok := presentes[r]; !ok {
t.Errorf("falta la parte %s", r)
}
}
// Todo XML debe parsear: un & o un < sin escapar rompe el archivo entero.
for nombre, cuerpo := range presentes {
d := xml.NewDecoder(strings.NewReader(cuerpo))
for {
_, err := d.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("XML inválido en %s: %v", nombre, err)
}
}
}
hoja := presentes["xl/worksheets/sheet1.xml"]
if !strings.Contains(hoja, "Diseño &amp; maquetación") {
t.Error("el & del texto no quedó escapado")
}
if !strings.Contains(hoja, "Wireframes &lt;UX&gt;") {
t.Error("los < > del texto no quedaron escapados")
}
if !strings.Contains(hoja, `<pane ySplit="1"`) {
t.Error("falta el encabezado congelado")
}
if !strings.Contains(hoja, `r="D3"`) {
t.Error("la última celda no quedó en la posición esperada (D3)")
}
}
func TestColumna(t *testing.T) {
casos := map[int]string{0: "A", 1: "B", 25: "Z", 26: "AA", 27: "AB", 51: "AZ", 52: "BA"}
for i, esperado := range casos {
if got := columna(i); got != esperado {
t.Errorf("columna(%d) = %q, esperaba %q", i, got, esperado)
}
}
}
// Excel rechaza el archivo si el nombre de hoja pasa de 31 chars o trae : \ / ? * [ ]
func TestLimpiarNombreHoja(t *testing.T) {
if got := limpiarNombreHoja("Plan/Cron*ograma"); strings.ContainsAny(got, `:\/?*[]`) {
t.Errorf("quedaron caracteres prohibidos: %q", got)
}
largo := strings.Repeat("a", 50)
if got := limpiarNombreHoja(largo); len([]rune(got)) != 31 {
t.Errorf("no se recortó a 31: %d", len([]rune(got)))
}
}
+11
View File
@@ -40,6 +40,17 @@
<!-- ─── ROADMAP ────────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Roadmap'" x-cloak>
{{ if .fases }}
<div class="flex justify-end mb-4">
<a href="/portal/proyecto/{{ .proyecto.Slug }}/cronograma.xlsx"
class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-slate-900 border border-slate-200 hover:border-slate-300 rounded-lg px-3 py-1.5 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
Descargar cronograma (Excel)
</a>
</div>
{{ end }}
<div class="relative">
<!-- Línea vertical -->
<div class="absolute left-6 top-0 bottom-0 w-0.5 bg-slate-200"></div>
+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())
}
+1
View File
@@ -21,6 +21,7 @@ func PortalRoutes(app fiber.Router) {
portal.Get("/dashboard", controllers.PortalDashboard)
portal.Get("/proyecto/:slug", controllers.PortalProyecto)
portal.Get("/proyecto/:slug/cronograma.xlsx", controllers.PortalDescargarCronograma)
// API JSON para Alpine.js
portal.Get("/api/proyectos", controllers.PortalGetProyectos)