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>
331 lines
19 KiB
HTML
331 lines
19 KiB
HTML
<div x-data="app" class="bg-white rounded-lg shadow">
|
||
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||
</div>
|
||
|
||
<div class="container mx-auto p-6 w-full">
|
||
<div class="justify-between items-center w-full md:flex mb-4">
|
||
<div>
|
||
<h1 class="text-2xl font-bold mb-1">Plantillas de Documento</h1>
|
||
<p class="text-sm text-gray-500">Fuente de verdad de cotizaciones, contratos, actas y cuentas de cobro generadas por IA</p>
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-3 md:mt-0">
|
||
<select x-model="filtroTipo" @change="loadData()" class="border border-gray-300 rounded p-2 text-sm">
|
||
<option value="">Todos los tipos</option>
|
||
<option value="cotizacion">Cotización</option>
|
||
<option value="contrato">Contrato</option>
|
||
<option value="acta">Acta</option>
|
||
<option value="cuenta_cobro">Cuenta de cobro</option>
|
||
</select>
|
||
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto">
|
||
<table class="table-auto w-full text-sm">
|
||
<thead class="text-left border-b border-gray-200 bg-gray-50">
|
||
<tr>
|
||
<th class="py-2 px-3">Nombre</th>
|
||
<th class="py-2 px-3">Tipo</th>
|
||
<th class="py-2 px-3">Versión</th>
|
||
<th class="py-2 px-3">Estado</th>
|
||
<th class="py-2 px-3 w-24"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="text-gray-600">
|
||
<template x-for="d in datos" :key="d.ID">
|
||
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
|
||
<td class="py-2 px-3">
|
||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700" x-text="d.tipo"></span>
|
||
</td>
|
||
<td class="py-2 px-3" x-text="'v'+d.version"></td>
|
||
<td class="py-2 px-3">
|
||
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
||
:class="d.activa ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||
x-text="d.activa ? 'Activa' : 'Inactiva'"></span>
|
||
</td>
|
||
<td class="py-2 px-3">
|
||
<div class="flex gap-2">
|
||
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
|
||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||
</button>
|
||
<button @click="openDelete(d)" title="Eliminar" class="text-gray-400 hover:text-red-500">
|
||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
<tr x-show="!loading && datos.length===0">
|
||
<td colspan="5" class="text-center text-gray-400 py-8">Sin plantillas</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
|
||
<span>Total: <span x-text="total"></span></span>
|
||
<div class="flex gap-1">
|
||
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40">‹</button>
|
||
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
|
||
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40">›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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 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">
|
||
<strong>Variables comunes:</strong>
|
||
<code>{{.Cliente}}</code>,
|
||
<code>{{.Alcance}}</code>,
|
||
<code>{{.Fecha}}</code>,
|
||
<code>{{.EmpresaNombre}}</code>,
|
||
<code>{{.Total}}</code>,
|
||
<code>{{range .Items}}...{{.Descripcion}}...{{end}}</code>
|
||
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
|
||
</div>
|
||
|
||
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
|
||
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
|
||
<p class="text-xs text-gray-500 mb-2">
|
||
Subí el archivo (PDF, Word .docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
|
||
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
|
||
</p>
|
||
<p class="text-xs text-gray-400 mb-2">
|
||
No usa un agente: esto no conversa ni consulta una base de conocimiento, convierte el documento y listo.
|
||
El modelo sale de <a href="/app/ai-config" class="underline">Configuración de IA</a> — asignale el módulo
|
||
<em>Plantillas de documento</em> a la config que quieras usar acá, o dejalo sin asignar y usa la de <em>IA / vCard</em>.
|
||
</p>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<input type="file" x-ref="archivo" accept=".pdf,.docx,.html,.htm,.txt,.md,image/*"
|
||
class="text-xs" />
|
||
<button type="button" @click="importar()" :disabled="importando"
|
||
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
|
||
<span x-text="importando ? 'Leyendo…' : 'Leer con IA'"></span>
|
||
</button>
|
||
<span x-show="avisoImport" x-text="avisoImport" class="text-xs"
|
||
:class="errorImport ? 'text-red-600' : 'text-green-600'"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<form @submit.prevent="save()">
|
||
<div class="grid grid-cols-3 gap-3 mb-3">
|
||
<div>
|
||
<label class="text-xs font-medium text-gray-600">Nombre *</label>
|
||
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
|
||
</div>
|
||
<div>
|
||
<label class="text-xs font-medium text-gray-600">Tipo</label>
|
||
<select x-model="form.tipo" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||
<option value="cotizacion">Cotización</option>
|
||
<option value="contrato">Contrato</option>
|
||
<option value="acta">Acta</option>
|
||
<option value="cuenta_cobro">Cuenta de cobro</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-end gap-2">
|
||
<label class="flex items-center gap-2 text-sm mb-2">
|
||
<input type="checkbox" x-model="form.activa" /> Activa
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
|
||
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal Eliminar -->
|
||
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
|
||
<p class="font-semibold mb-1">¿Eliminar plantilla?</p>
|
||
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
|
||
<div class="flex justify-center gap-3">
|
||
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="toast.show" x-cloak x-transition
|
||
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
|
||
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
|
||
x-text="toast.msg"></div>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('app', () => ({
|
||
loading: false,
|
||
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
|
||
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' },
|
||
|
||
async init() { await this.loadData(); },
|
||
|
||
async loadData() {
|
||
this.loading = true;
|
||
try {
|
||
const { data } = await axios.get(`/app/api/plantillas-documento?page=${this.page}&limit=${this.limit}&tipo=${this.filtroTipo}`);
|
||
this.datos = data.registros || [];
|
||
this.total = data.total || 0;
|
||
this.totalPages = data.totalPages || 1;
|
||
} catch(e) {
|
||
this.showToast(e.response?.data?.error || 'No se pudo cargar la lista', 'error');
|
||
}
|
||
this.loading = false;
|
||
},
|
||
|
||
openEdit(d) {
|
||
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; },
|
||
|
||
async importar() {
|
||
const f = this.$refs.archivo?.files?.[0];
|
||
if (!f) { this.errorImport = true; this.avisoImport = 'Elegí un archivo primero'; return; }
|
||
this.importando = true; this.avisoImport = ''; this.errorImport = false;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('archivo', f);
|
||
fd.append('tipo', this.form.tipo);
|
||
const { data } = await axios.post('/app/api/plantillas-documento/importar', fd);
|
||
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á 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
|
||
// (timeout, por ejemplo); mostrar el status ayuda a distinguirlo.
|
||
this.avisoImport = e.response?.data?.error
|
||
|| (e.response ? `El servidor respondió ${e.response.status}` : e.message);
|
||
}
|
||
this.importando = false;
|
||
},
|
||
|
||
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() {
|
||
this.addModal = this.editModal = this.deleteModal = false;
|
||
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 };
|
||
},
|
||
|
||
async save() {
|
||
this.loading = true;
|
||
try {
|
||
if (this.editModal) {
|
||
await axios.put(`/app/api/plantillas-documento/${this.selectedId}`, this.form);
|
||
} else {
|
||
await axios.post('/app/api/plantillas-documento', this.form);
|
||
}
|
||
this.showToast('Guardado');
|
||
this.closeModals();
|
||
await this.loadData();
|
||
} catch(e) {
|
||
this.templateError = e.response?.data?.error || '';
|
||
this.showToast(e.response?.data?.error||'Error', 'error');
|
||
}
|
||
this.loading = false;
|
||
},
|
||
|
||
async deleteItem() {
|
||
this.loading = true;
|
||
try {
|
||
await axios.delete(`/app/api/plantillas-documento/${this.selectedId}`);
|
||
this.showToast('Eliminado');
|
||
this.closeModals();
|
||
await this.loadData();
|
||
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
|
||
this.loading = false;
|
||
},
|
||
|
||
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
|
||
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
|
||
showToast(msg, type='ok') {
|
||
this.toast = { show:true, msg, type };
|
||
setTimeout(() => this.toast.show = false, 3000);
|
||
}
|
||
}));
|
||
});
|
||
</script>
|