From 86cdb2d3681fd52265aea6b7bcdfd859cf9e1744 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:28:06 -0500 Subject: [PATCH] =?UTF-8?q?feat(plantillas):=20ver=20c=C3=B3mo=20queda=20l?= =?UTF-8?q?a=20plantilla=20mientras=20se=20edita?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se editaba HTML a ciegas: había que guardar y generar un documento real para saber si estaba bien. Ahora el modal tiene un panel de vista previa al lado del editor, que se actualiza mientras se escribe y se abre solo al editar una plantilla o al importar una con IA. Se ejecuta la plantilla de verdad contra datos de ejemplo, no se reemplaza texto: es la única forma de que {{range .Items}} y los campos anidados se vean como van a salir, y de que un error de sintaxis aparezca mientras se edita en vez de al generar el PDF. Los datos de ejemplo salen de DatosBaseDocumento, igual que en producción, para que la vista previa no muestre una cosa y el documento otra. El iframe va en sandbox sin scripts ni same-origin: el HTML lo escribe un admin, pero no tiene por qué correr con los permisos del panel. De paso, la ayuda de variables ofrecía {{.Cliente.Nit}}, que no existe en el modelo — el campo es Documento. Co-Authored-By: Claude Opus 5 --- pkg/services/plantilla_import_service.go | 6 +- pkg/services/plantilla_preview_service.go | 67 ++++++++++++++++++ pkg/services/plantilla_preview_test.go | 68 ++++++++++++++++++ .../automatizacion/plantillas_documento.html | 70 ++++++++++++++++--- .../plantilla_documento_controller.go | 18 +++++ rest/routes/renovaciones.go | 1 + 6 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 pkg/services/plantilla_preview_service.go create mode 100644 pkg/services/plantilla_preview_test.go diff --git a/pkg/services/plantilla_import_service.go b/pkg/services/plantilla_import_service.go index a1e56b6..6c719ee 100644 --- a/pkg/services/plantilla_import_service.go +++ b/pkg/services/plantilla_import_service.go @@ -11,12 +11,12 @@ import ( // renderizarse. Sale de DatosBaseDocumento + lo que arma cada generador // (ver CrearCotizacion, contrato_documento_service, cuenta_cobro_documento_service). var variablesPorTipo = map[string]string{ - "cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Cliente.Email}}, {{.Cliente.Telefono}}, + "cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Cliente.Email}}, {{.Cliente.Telefono}}, {{.Alcance}}, {{.TipoProyecto}}, {{.Total}}, {{range .Items}} … {{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}} … {{end}}`, - "contrato": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`, + "contrato": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`, "acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`, - "cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`, + "cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`, } const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}` diff --git a/pkg/services/plantilla_preview_service.go b/pkg/services/plantilla_preview_service.go new file mode 100644 index 0000000..3b280cd --- /dev/null +++ b/pkg/services/plantilla_preview_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "bytes" + "fmt" + "text/template" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// RenderizarPlantillaEjemplo ejecuta la plantilla con datos de muestra y +// devuelve el HTML resultante. +// +// Se ejecuta de verdad, no se hace un reemplazo de texto: es la única forma de +// que {{range .Items}} y los campos anidados se vean como van a salir, y de que +// un error de sintaxis aparezca mientras se edita y no al generar el documento. +func RenderizarPlantillaEjemplo(tipo, contenidoHTML string) (string, error) { + tmpl, err := template.New("preview").Parse(contenidoHTML) + if err != nil { + return "", fmt.Errorf("sintaxis inválida: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, DatosDeEjemploPlantilla(tipo)); err != nil { + return "", fmt.Errorf("no se pudo renderizar: %w", err) + } + return buf.String(), nil +} + +// DatosDeEjemploPlantilla arma los mismos campos que le pasa cada generador +// real (ver CrearCotizacion, contrato_documento_service, cuenta_cobro), con +// valores inventados. Si esto se desincroniza de los generadores, la vista +// previa miente — por eso sale de DatosBaseDocumento, igual que en producción. +func DatosDeEjemploPlantilla(tipo string) map[string]interface{} { + cliente := &models.Cliente{ + Nombre: "Acme S.A.S.", + Empresa: "Acme S.A.S.", + Documento: "900.123.456-7", + Email: "contacto@acme.com", + Telefono: "+57 300 123 4567", + } + + extra := map[string]interface{}{ + "Cliente": cliente, + "Alcance": "Desarrollo del sitio web institucional, con panel de administración y tres integraciones.", + "TipoProyecto": "Sitio web", + "Total": 4500000.0, + "Items": []ItemCotizacion{ + {Descripcion: "Diseño de interfaz", Cantidad: 1, ValorUnitario: 1500000, Unidad: "servicio"}, + {Descripcion: "Desarrollo frontend", Cantidad: 40, ValorUnitario: 50000, Unidad: "hora"}, + {Descripcion: "Integración con pasarela de pagos", Cantidad: 1, ValorUnitario: 1000000, Unidad: "servicio"}, + }, + } + + switch tipo { + case "contrato": + extra["Servicio"] = "Mantenimiento mensual del sitio" + extra["Periodicidad"] = "mensual" + case "acta": + extra["Proyecto"] = "Sitio web Acme" + extra["Entregables"] = "Sitio publicado, manual de uso y capacitación." + case "cuenta_cobro": + extra["Concepto"] = "Mantenimiento mensual — marzo" + extra["Numero"] = "CC-0042" + } + + return DatosBaseDocumento(extra) +} diff --git a/pkg/services/plantilla_preview_test.go b/pkg/services/plantilla_preview_test.go new file mode 100644 index 0000000..1692e56 --- /dev/null +++ b/pkg/services/plantilla_preview_test.go @@ -0,0 +1,68 @@ +package services + +import ( + "strings" + "testing" +) + +// La vista previa ejecuta la plantilla de verdad: es lo que hace que un +// {{range}} se vea como va a salir y que un error de sintaxis aparezca mientras +// se edita, y no recién al generar el documento. +func TestRenderizarPlantillaEjemplo(t *testing.T) { + html := `

{{.Cliente.Nombre}} — {{.Cliente.Documento}}

+

{{.Alcance}}

+{{range .Items}}{{end}}
{{.Descripcion}}{{.Cantidad}}
+

Total: {{.Total}} — {{.Fecha}} — {{.EmpresaNombre}}

` + + out, err := RenderizarPlantillaEjemplo("cotizacion", html) + if err != nil { + t.Fatalf("RenderizarPlantillaEjemplo: %v", err) + } + for _, quiero := range []string{"Acme S.A.S.", "900.123.456-7", "Diseño de interfaz", "Integración con pasarela", "U-SITE"} { + if !strings.Contains(out, quiero) { + t.Errorf("falta %q en:\n%s", quiero, out) + } + } + if strings.Contains(out, "{{") { + t.Errorf("quedaron variables sin resolver:\n%s", out) + } +} + +func TestRenderizarPlantillaEjemploAvisaDeErrores(t *testing.T) { + if _, err := RenderizarPlantillaEjemplo("cotizacion", "{{range .Items}}sin fin"); err == nil { + t.Error("un {{range}} sin {{end}} debería dar error") + } + // Una variable que no existe no rompe: se renderiza como "", igual + // que en la generación real. Eso se ve en la vista previa, que es el punto — + // hacerla más estricta que producción rechazaría plantillas que sí andan. + out, err := RenderizarPlantillaEjemplo("cotizacion", "Hola {{.NoExiste}}") + if err != nil { + t.Fatalf("no debería fallar: %v", err) + } + if !strings.Contains(out, "no value") { + t.Errorf("una variable inexistente tendría que notarse en la vista previa: %q", out) + } +} + +// Cada tipo agrega sus propios campos; si esto se desincroniza de los +// generadores, la vista previa muestra una cosa y el PDF sale con otra. +func TestDatosDeEjemploPorTipo(t *testing.T) { + casos := map[string][]string{ + "contrato": {"Servicio", "Periodicidad"}, + "acta": {"Proyecto", "Entregables"}, + "cuenta_cobro": {"Concepto", "Numero"}, + } + for tipo, campos := range casos { + datos := DatosDeEjemploPlantilla(tipo) + for _, campo := range campos { + if _, ok := datos[campo]; !ok { + t.Errorf("faltan datos de ejemplo de %q para el tipo %q", campo, tipo) + } + } + for _, comun := range []string{"Cliente", "Fecha", "EmpresaNombre", "Total"} { + if _, ok := datos[comun]; !ok { + t.Errorf("falta el campo común %q en el tipo %q", comun, tipo) + } + } + } +} diff --git a/resources/views/automatizacion/plantillas_documento.html b/resources/views/automatizacion/plantillas_documento.html index 8b8410d..c57f010 100644 --- a/resources/views/automatizacion/plantillas_documento.html +++ b/resources/views/automatizacion/plantillas_documento.html @@ -76,7 +76,8 @@
-
+

@@ -134,13 +135,36 @@
-
- - -

+
+
+
+ + +
+ +

+
+ +
+
+ + actualizando… +
+ + +

+

+ Cliente, ítems y totales son inventados; al generar el documento real se reemplazan por los del cliente. +

+
@@ -178,6 +202,7 @@ document.addEventListener('alpine:init', () => { addModal: false, editModal: false, deleteModal: false, selectedId: null, templateError: '', importando: false, avisoImport: '', errorImport: false, + mostrarPreview: false, previewCargando: false, previewError: '', form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true }, toast: { show:false, msg:'', type:'ok' }, @@ -200,6 +225,8 @@ document.addEventListener('alpine:init', () => { this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa }; this.selectedId = d.ID; this.editModal = true; + this.mostrarPreview = true; + this.$nextTick(() => this.previsualizar()); }, openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; }, @@ -215,7 +242,9 @@ document.addEventListener('alpine:init', () => { this.form.contenido_html = data.contenido_html || ''; if (!this.form.nombre) this.form.nombre = f.name.replace(/\.[^.]+$/, ''); this.errorImport = !!data.aviso; - this.avisoImport = data.aviso || 'Listo, revisá el HTML abajo'; + this.avisoImport = data.aviso || 'Listo, revisá cómo quedó'; + this.mostrarPreview = true; + await this.previsualizar(); } catch(e) { this.errorImport = true; // Sin data.error el fallo no vino de la app sino del proxy @@ -228,6 +257,28 @@ document.addEventListener('alpine:init', () => { async validar() { this.templateError = ''; + if (this.mostrarPreview) await this.previsualizar(); + }, + + async previsualizar() { + if (!this.form.contenido_html) { this.pintarPreview(''); return; } + this.previewCargando = true; this.previewError = ''; + try { + const { data } = await axios.post('/app/api/plantillas-documento/previsualizar', { + tipo: this.form.tipo, + contenido_html: this.form.contenido_html, + }); + this.pintarPreview(data.html || ''); + } catch (e) { + this.previewError = e.response?.data?.error || e.message; + } + this.previewCargando = false; + }, + + // srcdoc y no document.write: con el iframe en sandbox no hay acceso a + // su documento desde acá. + pintarPreview(html) { + if (this.$refs.preview) this.$refs.preview.srcdoc = html; }, closeModals() { @@ -235,6 +286,7 @@ document.addEventListener('alpine:init', () => { this.selectedId = null; this.templateError = ''; this.avisoImport = ''; this.errorImport = false; + this.mostrarPreview = false; this.previewError = ''; this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true }; }, diff --git a/rest/controllers/plantilla_documento_controller.go b/rest/controllers/plantilla_documento_controller.go index 08c0fdc..060e565 100644 --- a/rest/controllers/plantilla_documento_controller.go +++ b/rest/controllers/plantilla_documento_controller.go @@ -151,6 +151,24 @@ func ImportarPlantillaDocumento(c *fiber.Ctx) error { return c.JSON(fiber.Map{"contenido_html": html}) } +// PrevisualizarPlantillaDocumento renderiza la plantilla con datos de ejemplo +// para ver cómo queda antes de guardarla. +// POST /app/api/plantillas-documento/previsualizar {tipo, contenido_html} +func PrevisualizarPlantillaDocumento(c *fiber.Ctx) error { + var req struct { + Tipo string `json:"tipo"` + ContenidoHTML string `json:"contenido_html"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + html, err := services.RenderizarPlantillaEjemplo(req.Tipo, req.ContenidoHTML) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"html": html}) +} + // ─── Tarifas ──────────────────────────────────────────────────────────────── func GetTarifas(c *fiber.Ctx) error { diff --git a/rest/routes/renovaciones.go b/rest/routes/renovaciones.go index e12aba4..47a9d38 100644 --- a/rest/routes/renovaciones.go +++ b/rest/routes/renovaciones.go @@ -86,6 +86,7 @@ func RenovacionesRoutes(protected fiber.Router) { // ─── Automatización IA: Plantillas de documento ──────────────────── protected.Get("/automatizacion/plantillas", middlewares.MenuMiddleware, controllers.PlantillasDocumentoView) protected.Post("/api/plantillas-documento/importar", controllers.ImportarPlantillaDocumento) + protected.Post("/api/plantillas-documento/previsualizar", controllers.PrevisualizarPlantillaDocumento) protected.Get("/api/plantillas-documento", controllers.GetPlantillasDocumento) protected.Get("/api/plantillas-documento/:id", controllers.GetPlantillaDocumento) protected.Post("/api/plantillas-documento", controllers.CreatePlantillaDocumento)