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
@@ -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 };
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user