diff --git a/pkg/services/xlsx.go b/pkg/services/xlsx.go new file mode 100644 index 0000000..89b6f45 --- /dev/null +++ b/pkg/services/xlsx.go @@ -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(``) + b.WriteString(``) + // Congelar el encabezado: en un cronograma largo, perderlo al scrollear + // es lo primero que molesta. + b.WriteString(``) + b.WriteString(``) + b.WriteString(anchoColumnas(filas)) + b.WriteString(``) + for i, fila := range filas { + estilo := "" + if i == 0 { + estilo = ` s="1"` // encabezado en negrita + } + fmt.Fprintf(&b, ``, i+1) + for j, celda := range fila { + fmt.Fprintf(&b, + `%s`, + columna(j), i+1, estilo, escaparXML(celda)) + } + b.WriteString(``) + } + b.WriteString(``) + 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(``) + 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, ``, j+1, j+1, a+2) + } + b.WriteString(``) + return b.String() +} + +const contentTypesXML = ` + + + + + + +` + +const relsXML = ` + + +` + +const workbookXML = ` + + +` + +const workbookRelsXML = ` + + + +` + +// Dos estilos: 0 normal, 1 negrita (el encabezado). +const stylesXML = ` + + + + + + + +` diff --git a/pkg/services/xlsx_test.go b/pkg/services/xlsx_test.go new file mode 100644 index 0000000..44ca746 --- /dev/null +++ b/pkg/services/xlsx_test.go @@ -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 ", "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, `
+ {{ if .fases }} + + {{ end }}
diff --git a/rest/controllers/portal_controller.go b/rest/controllers/portal_controller.go index 10e779e..e411dba 100644 --- a/rest/controllers/portal_controller.go +++ b/rest/controllers/portal_controller.go @@ -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()) +} diff --git a/rest/routes/portal.go b/rest/routes/portal.go index 794fc76..c07f4c3 100644 --- a/rest/routes/portal.go +++ b/rest/routes/portal.go @@ -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)