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:
co-authored by
Claude Sonnet 5
parent
5182c45d11
commit
62c644b815
@@ -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>`
|
||||
@@ -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 & maquetación") {
|
||||
t.Error("el & del texto no quedó escapado")
|
||||
}
|
||||
if !strings.Contains(hoja, "Wireframes <UX>") {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user