feat(plantillas): ver cómo queda la plantilla mientras se edita
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f95e67595
commit
86cdb2d368
@@ -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}}`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 := `<h1>{{.Cliente.Nombre}} — {{.Cliente.Documento}}</h1>
|
||||
<p>{{.Alcance}}</p>
|
||||
<table>{{range .Items}}<tr><td>{{.Descripcion}}</td><td>{{.Cantidad}}</td></tr>{{end}}</table>
|
||||
<p>Total: {{.Total}} — {{.Fecha}} — {{.EmpresaNombre}}</p>`
|
||||
|
||||
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 "<no value>", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,8 @@
|
||||
|
||||
<!-- Modal Crear / Editar -->
|
||||
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 p-6 max-h-[90vh] overflow-y-auto" @click.stop>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full mx-4 p-6 max-h-[90vh] overflow-y-auto transition-all"
|
||||
:class="mostrarPreview ? 'max-w-6xl' : 'max-w-4xl'" @click.stop>
|
||||
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2>
|
||||
|
||||
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
|
||||
@@ -134,13 +135,36 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-600 block mb-1">HTML de la plantilla (Go text/template) *</label>
|
||||
<textarea x-model="form.contenido_html" rows="16"
|
||||
@input.debounce.600ms="validar()"
|
||||
:class="templateError ? 'border-red-400' : ''"
|
||||
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
|
||||
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p>
|
||||
<div class="grid gap-3" :class="mostrarPreview ? 'lg:grid-cols-2' : ''">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs font-medium text-gray-600">HTML de la plantilla (Go text/template) *</label>
|
||||
<button type="button" @click="mostrarPreview = !mostrarPreview; if (mostrarPreview) previsualizar()"
|
||||
class="text-xs px-2 py-1 border rounded">
|
||||
<span x-text="mostrarPreview ? 'Ocultar vista previa' : '👁 Ver cómo queda'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea x-model="form.contenido_html" rows="16"
|
||||
@input.debounce.600ms="validar()"
|
||||
:class="templateError ? 'border-red-400' : ''"
|
||||
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
|
||||
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="mostrarPreview" x-cloak>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs font-medium text-gray-600">Vista previa (con datos de ejemplo)</label>
|
||||
<span x-show="previewCargando" class="text-xs text-gray-400">actualizando…</span>
|
||||
</div>
|
||||
<!-- sandbox sin allow-scripts ni allow-same-origin: el HTML lo
|
||||
escribe un admin, pero no tiene por qué correr con los
|
||||
permisos del panel. -->
|
||||
<iframe x-ref="preview" sandbox="" class="w-full h-[26rem] border rounded bg-white"></iframe>
|
||||
<p x-show="previewError" x-text="previewError" class="text-red-500 text-xs mt-1"></p>
|
||||
<p class="text-[11px] text-gray-400 mt-1">
|
||||
Cliente, ítems y totales son inventados; al generar el documento real se reemplazan por los del cliente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user