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)